From 5997ef0423a8ee4445b9efec0fb8606d116762e6 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 18 Aug 2026 14:07:19 -0700 Subject: [PATCH] refactor(ui): migrate the vector store creation form to shadcn (#37353) * refactor(ui): migrate the vector store creation form to shadcn CreateVectorStore and S3VectorsConfig were the last antd Form.Item users on the vector stores page. Both are now built from the shared Field primitives and shadcn controls, so the page picks up the design tokens and dark mode. CreateVectorStore's antd Form was inert: no Form.Item carried a name, there was no onFinish, and the submit button sat outside the form element, so the form store never held anything. Form.useForm is dropped rather than replaced with react-hook-form, and the existing imperative validation is unchanged. S3VectorsConfig's four Form.Item wrappers had no name either, so its inputs were already prop-controlled and decoupled from the parent store. The embedding model picker keeps its typeahead by moving to Combobox. The submit payload is unchanged. A new characterization suite pins it: it was written against the antd originals, proved green there first, and passes unedited against the migration. * test(ui): cover the S3 embedding model combobox end to end The migration moved this control from an antd Select with showSearch to a Combobox, and nothing exercised it: the suite pinned the payload but never loaded the option list, filtered it, or selected from it. The case drives the whole interaction. It stubs three models, one of which is a chat model, opens the list and asserts the chat model is absent, types to filter, selects the remaining embedding model and asserts it arrives in the providerParams argument. Proved green against the antd originals of both files first, then unedited against the migration. It queries the control by role rather than by label, because the antd original rendered a label with no control associated to it, which the migration fixes. --- ...reateVectorStore.characterization.test.tsx | 231 +++++++++++ .../_components/CreateVectorStore.test.tsx | 23 +- .../_components/CreateVectorStore.tsx | 361 ++++++++---------- .../_components/S3VectorsConfig.tsx | 190 +++++---- 4 files changed, 487 insertions(+), 318 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.characterization.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.characterization.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.characterization.test.tsx new file mode 100644 index 00000000000..c79d35d46c3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.characterization.test.tsx @@ -0,0 +1,231 @@ +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import CreateVectorStore from "./CreateVectorStore"; +import * as networking from "@/components/networking"; +import * as fetchModels from "@/components/llm_calls/fetch_models"; + +vi.mock("@/components/networking", () => ({ + ragIngestCall: vi.fn(), +})); + +vi.mock("@/components/llm_calls/fetch_models", () => ({ + fetchAvailableModels: vi.fn(), +})); + +vi.mock("@/components/vector_store_providers", () => ({ + VectorStoreProviders: { + BEDROCK: "Amazon Bedrock", + S3Vectors: "AWS S3 Vectors", + PGVECTOR: "PG Vector", + }, + vectorStoreProviderMap: { + BEDROCK: "bedrock", + S3Vectors: "s3_vectors", + PGVECTOR: "pg_vector", + }, + vectorStoreProviderLogoMap: { + "Amazon Bedrock": "https://example.com/bedrock.png", + "AWS S3 Vectors": "https://example.com/aws.png", + "PG Vector": "https://example.com/pg.png", + }, + getProviderSpecificFields: vi.fn((provider: string) => { + if (provider === "pg_vector") { + return [ + { + name: "api_base", + label: "API Base", + tooltip: "Base URL of the pgvector server", + placeholder: "http://localhost:8000", + required: true, + type: "text", + }, + { + name: "api_key", + label: "API Key", + tooltip: "Secret for the pgvector server", + placeholder: "sk-...", + required: false, + type: "password", + }, + { + name: "embedding_model", + label: "Embedding Model", + tooltip: "Model used to embed documents", + placeholder: "text-embedding-3-small", + required: false, + type: "select", + }, + ]; + } + return []; + }), +})); + +const uploadFile = async (name = "test.pdf") => { + const file = new File(["test content"], name, { type: "application/pdf" }); + const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await act(async () => { + fireEvent.change(uploadInput, { target: { files: [file] } }); + }); + await screen.findByText(/Uploaded Documents \(1\)/); +}; + +const pickProvider = async (label: string) => { + const user = userEvent.setup(); + const trigger = screen.getAllByRole("combobox")[0]; + await user.click(trigger); + const option = await screen.findByText(label); + await user.click(option); +}; + +const clickCreate = async () => { + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /Create Vector Store/i })); + }); +}; + +describe("CreateVectorStore submit payload characterization", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchModels.fetchAvailableModels).mockResolvedValue([]); + vi.mocked(networking.ragIngestCall).mockResolvedValue({ + id: "test-id", + status: "completed", + vector_store_id: "vs_123", + file_id: "file_123", + }); + }); + + it("sends undefined, not empty string, for an untouched name and description", async () => { + render(); + await uploadFile(); + await clickCreate(); + + await waitFor(() => expect(networking.ragIngestCall).toHaveBeenCalledTimes(1)); + expect(networking.ragIngestCall).toHaveBeenCalledWith( + "test-token", + expect.any(File), + "bedrock", + undefined, + undefined, + undefined, + {}, + ); + }); + + it("forwards the typed name and description verbatim", async () => { + render(); + await uploadFile(); + + fireEvent.change(screen.getByPlaceholderText("e.g., Product Documentation, Customer Support KB"), { + target: { value: " Product Docs " }, + }); + fireEvent.change(screen.getByPlaceholderText("e.g., Contains all product documentation and user guides"), { + target: { value: "All the guides" }, + }); + await clickCreate(); + + await waitFor(() => expect(networking.ragIngestCall).toHaveBeenCalledTimes(1)); + expect(networking.ragIngestCall).toHaveBeenCalledWith( + "test-token", + expect.any(File), + "bedrock", + undefined, + " Product Docs ", + "All the guides", + {}, + ); + }); + + it("accumulates provider-specific fields into the providerParams argument", async () => { + render(); + await uploadFile(); + await pickProvider("PG Vector"); + + fireEvent.change(screen.getByPlaceholderText("http://localhost:8000"), { + target: { value: "http://pg.internal:8000" }, + }); + fireEvent.change(screen.getByPlaceholderText("sk-..."), { target: { value: "sk-secret" } }); + await clickCreate(); + + await waitFor(() => expect(networking.ragIngestCall).toHaveBeenCalledTimes(1)); + expect(networking.ragIngestCall).toHaveBeenCalledWith( + "test-token", + expect.any(File), + "pg_vector", + undefined, + undefined, + undefined, + { api_base: "http://pg.internal:8000", api_key: "sk-secret" }, + ); + }); + + it("loads the S3 embedding models, filters them by typing and sends the chosen one", async () => { + vi.mocked(fetchModels.fetchAvailableModels).mockResolvedValue([ + { model_group: "text-embedding-3-small", mode: "embedding" }, + { model_group: "text-embedding-3-large", mode: "embedding" }, + { model_group: "gpt-5", mode: "chat" }, + ] as Awaited>); + const user = userEvent.setup(); + render(); + await uploadFile(); + await pickProvider("AWS S3 Vectors"); + + const modelInput = screen.getAllByRole("combobox").at(-1) as HTMLElement; + await user.click(modelInput); + expect((await screen.findAllByText("text-embedding-3-small")).at(-1)).toBeInTheDocument(); + expect(screen.queryByText("gpt-5")).not.toBeInTheDocument(); + + await user.type(modelInput, "large"); + await user.click((await screen.findAllByText("text-embedding-3-large")).at(-1) as HTMLElement); + await clickCreate(); + + await waitFor(() => expect(networking.ragIngestCall).toHaveBeenCalledTimes(1)); + expect(vi.mocked(networking.ragIngestCall).mock.calls.at(-1)?.[6]).toEqual({ + embedding_model: "text-embedding-3-large", + }); + }); + + it("blocks the submit when a required provider field is missing", async () => { + render(); + await uploadFile(); + await pickProvider("PG Vector"); + await clickCreate(); + + expect(networking.ragIngestCall).not.toHaveBeenCalled(); + }); + + it("renders the password provider field as a masked input", async () => { + render(); + await pickProvider("PG Vector"); + + expect(screen.getByPlaceholderText("sk-...")).toHaveAttribute("type", "password"); + }); + + it("reuses the vector store id returned by the first ingest for later documents", async () => { + render(); + + const files = [ + new File(["a"], "a.pdf", { type: "application/pdf" }), + new File(["b"], "b.pdf", { type: "application/pdf" }), + ]; + const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await act(async () => { + fireEvent.change(uploadInput, { target: { files } }); + }); + await screen.findByText(/Uploaded Documents \(2\)/); + await clickCreate(); + + await waitFor(() => expect(networking.ragIngestCall).toHaveBeenCalledTimes(2)); + expect(vi.mocked(networking.ragIngestCall).mock.calls[0][3]).toBeUndefined(); + expect(vi.mocked(networking.ragIngestCall).mock.calls[1][3]).toBe("vs_123"); + }); + + it("does not submit at all when no document has been uploaded", async () => { + render(); + await clickCreate(); + + expect(networking.ragIngestCall).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx index 7405fa5c2fc..e0bd4feef59 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx @@ -1,4 +1,5 @@ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import CreateVectorStore from "./CreateVectorStore"; import * as networking from "@/components/networking"; @@ -204,17 +205,10 @@ describe("CreateVectorStore", () => { // Find and click the provider dropdown const providerSelect = screen.getByRole("combobox"); - await act(async () => { - fireEvent.mouseDown(providerSelect); - }); + await userEvent.click(providerSelect); // Wait for dropdown options to appear - await waitFor(() => { - const s3Option = screen.queryByText("AWS S3 Vectors"); - if (s3Option) { - fireEvent.click(s3Option); - } - }); + await userEvent.click(await screen.findByText("AWS S3 Vectors")); // Check if S3-specific fields are displayed await waitFor(() => { @@ -244,16 +238,9 @@ describe("CreateVectorStore", () => { // Select S3 Vectors provider const providerSelect = screen.getByRole("combobox"); - await act(async () => { - fireEvent.mouseDown(providerSelect); - }); + await userEvent.click(providerSelect); - await waitFor(() => { - const s3Option = screen.queryByText("AWS S3 Vectors"); - if (s3Option) { - fireEvent.click(s3Option); - } - }); + await userEvent.click(await screen.findByText("AWS S3 Vectors")); // Try to create without filling required fields const createButton = screen.getByRole("button", { name: /Create Vector Store/i }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx index a0a2759eccb..baa0170c278 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx @@ -1,9 +1,10 @@ import React, { useState } from "react"; import { Card, Title, Text } from "@tremor/react"; -import { Upload, Button, Select, Form, Alert, Tooltip, Input } from "antd"; +import { Upload, Alert } from "antd"; import { toast } from "@/lib/toast"; -import { InboxOutlined, InfoCircleOutlined } from "@ant-design/icons"; +import { InboxOutlined } from "@ant-design/icons"; import type { UploadProps } from "antd"; +import { CircleHelp } from "lucide-react"; import { ragIngestCall } from "@/components/networking"; import { DocumentUpload, RAGIngestResponse } from "@/components/vector_store_management/types"; import DocumentsTable from "./DocumentsTable"; @@ -15,24 +16,42 @@ import { VectorStoreFieldConfig, } from "@/components/vector_store_providers"; import { Logo } from "@/components/molecules/logo/Logo"; +import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import S3VectorsConfig from "./S3VectorsConfig"; const { Dragger } = Upload; +const asText = (value: unknown): string => (typeof value === "string" ? value : ""); + +const labelWithHint = (label: string, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + +); + interface CreateVectorStoreProps { accessToken: string | null; onSuccess?: (vectorStoreId: string) => void; } const CreateVectorStore: React.FC = ({ accessToken, onSuccess }) => { - const [form] = Form.useForm(); const [documents, setDocuments] = useState([]); const [isCreating, setIsCreating] = useState(false); const [selectedProvider, setSelectedProvider] = useState("bedrock"); const [vectorStoreName, setVectorStoreName] = useState(""); const [vectorStoreDescription, setVectorStoreDescription] = useState(""); const [ingestResults, setIngestResults] = useState([]); - const [providerParams, setProviderParams] = useState>({}); + const [providerParams, setProviderParams] = useState>({}); const uploadProps: UploadProps = { name: "file", @@ -108,11 +127,13 @@ const CreateVectorStore: React.FC = ({ accessToken, onSu // S3 Vectors specific validation if (selectedProvider === "s3_vectors") { - if (providerParams.vector_bucket_name && providerParams.vector_bucket_name.length < 3) { + const bucketName = asText(providerParams.vector_bucket_name); + const indexName = asText(providerParams.index_name); + if (bucketName && bucketName.length < 3) { toast.warning("Vector bucket name must be at least 3 characters"); return; } - if (providerParams.index_name && providerParams.index_name.length > 0 && providerParams.index_name.length < 3) { + if (indexName && indexName.length > 0 && indexName.length < 3) { toast.warning("Index name must be at least 3 characters if provided"); return; } @@ -186,225 +207,165 @@ const CreateVectorStore: React.FC = ({ accessToken, onSu }; return ( -
-
- Create Vector Store - - Upload documents and select a provider to create a new vector store with embedded content. - -
- - {/* Upload Area */} - -
- Step 1: Upload Documents - - Upload one or more documents (PDF, TXT, DOCX, MD). Maximum file size: 50MB per file. + +
+
+ Create Vector Store + + Upload documents and select a provider to create a new vector store with embedded content.
- -

- -

-

Click or drag files to this area to upload

-

Support for single or bulk upload. Supported formats: PDF, TXT, DOCX, MD

-
- - {/* Documents Table */} - {documents.length > 0 && ( + {/* Upload Area */}
- Uploaded Documents ({documents.length}) -
- -
- )} - - {/* Provider Selection and Vector Store Details */} - -
-
- Step 2: Configure Vector Store - - Choose the provider and optionally provide a name and description for your vector store. + Step 1: Upload Documents + + Upload one or more documents (PDF, TXT, DOCX, MD). Maximum file size: 50MB per file.
+ +

+ +

+

Click or drag files to this area to upload

+

Support for single or bulk upload. Supported formats: PDF, TXT, DOCX, MD

+
+ -
- - Vector Store Name{" "} - - - - - } - > - setVectorStoreName(e.target.value)} - placeholder="e.g., Product Documentation, Customer Support KB" - size="large" - className="rounded-md" - /> - + {/* Documents Table */} + {documents.length > 0 && ( + +
+ Uploaded Documents ({documents.length}) +
+ +
+ )} - - Description{" "} - - - - - } - > - setVectorStoreDescription(e.target.value)} - placeholder="e.g., Contains all product documentation and user guides" - rows={2} - size="large" - className="rounded-md" - /> - + {/* Provider Selection and Vector Store Details */} + +
+
+ Step 2: Configure Vector Store + + Choose the provider and optionally provide a name and description for your vector store. + +
- - Provider{" "} - - - - - } - required - > - setVectorStoreName(e.target.value)} + placeholder="e.g., Product Documentation, Customer Support KB" + /> + + + + + {labelWithHint("Description", "Optional: Describe what this vector store contains")} + +