mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-20 00:11:50 +00:00
Merge branch 'main' into litellm_release_notes_01_26_2026
This commit is contained in:
commit
0e56e44701
29 changed files with 2610 additions and 311 deletions
|
|
@ -51,12 +51,14 @@ LiteLLM is a unified interface for 100+ LLMs that:
|
|||
|
||||
### MAKING CODE CHANGES FOR THE UI (IGNORE FOR BACKEND)
|
||||
|
||||
1. **Use Common Components as much as possible**:
|
||||
1. **Tremor is DEPRECATED, do not use Tremor components in new features/changes**
|
||||
- The only exception is the Tremor Table component and its required Tremor Table sub components.
|
||||
|
||||
2. **Use Common Components as much as possible**:
|
||||
- These are usually defined in the `common_components` directory
|
||||
- Use these components as much as possible and avoid building new components unless needed
|
||||
- Tremor components are deprecated; prefer using Ant Design (AntD) as much as possible
|
||||
|
||||
2. **Testing**:
|
||||
3. **Testing**:
|
||||
- The codebase uses **Vitest** and **React Testing Library**
|
||||
- **Query Priority Order**: Use query methods in this order: `getByRole`, `getByLabelText`, `getByPlaceholderText`, `getByText`, `getByTestId`
|
||||
- **Always use `screen`** instead of destructuring from `render()` (e.g., use `screen.getByText()` not `getByText`)
|
||||
|
|
|
|||
|
|
@ -267,6 +267,7 @@ Support for more providers. Missing a provider or LLM Platform, raise a [feature
|
|||
<td><img height="60" alt="Greptile" src="https://github.com/user-attachments/assets/0be4bd8a-7cfa-48d3-9090-f415fe948280" /></td>
|
||||
<td><img height="60" alt="OpenHands" src="https://github.com/user-attachments/assets/a6150c4c-149e-4cae-888b-8b92be6e003f" /></td>
|
||||
<td><h2>Netflix</h2></td>
|
||||
<td><img height="60" alt="OpenAI Agents SDK" src="https://github.com/user-attachments/assets/c02f7be0-8c2e-4d27-aea7-7c024bfaebc0" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,184 @@ from litellm.proxy.common_utils.http_parsing_utils import (
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
def _build_file_metadata_entry(
|
||||
response: Any,
|
||||
file_data: Optional[Tuple[str, bytes, str]] = None,
|
||||
file_url: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build a file metadata entry for storing in vector_store_metadata.
|
||||
|
||||
Args:
|
||||
response: The response from litellm.aingest containing file_id
|
||||
file_data: Optional tuple of (filename, content, content_type)
|
||||
file_url: Optional URL if file was ingested from URL
|
||||
|
||||
Returns:
|
||||
Dictionary with file metadata (file_id, filename, file_url, ingested_at, etc.)
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Extract file_id from response
|
||||
file_id = None
|
||||
if hasattr(response, "get"):
|
||||
file_id = response.get("file_id")
|
||||
elif hasattr(response, "file_id"):
|
||||
file_id = response.file_id
|
||||
|
||||
# Extract file information from file_data tuple
|
||||
filename = None
|
||||
file_size = None
|
||||
content_type = None
|
||||
|
||||
if file_data:
|
||||
filename = file_data[0]
|
||||
file_size = len(file_data[1]) if len(file_data) > 1 else None
|
||||
content_type = file_data[2] if len(file_data) > 2 else None
|
||||
|
||||
# Build file metadata entry
|
||||
file_entry = {
|
||||
"file_id": file_id,
|
||||
"filename": filename,
|
||||
"file_url": file_url,
|
||||
"ingested_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
# Add optional fields if available
|
||||
if file_size is not None:
|
||||
file_entry["file_size"] = file_size
|
||||
if content_type is not None:
|
||||
file_entry["content_type"] = content_type
|
||||
|
||||
return file_entry
|
||||
|
||||
|
||||
async def _save_vector_store_to_db_from_rag_ingest(
|
||||
response: Any,
|
||||
ingest_options: Dict[str, Any],
|
||||
prisma_client,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
file_data: Optional[Tuple[str, bytes, str]] = None,
|
||||
file_url: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Helper function to save a newly created vector store from RAG ingest to the database.
|
||||
|
||||
This function:
|
||||
- Extracts vector store ID and config from the ingest response
|
||||
- Checks if the vector store already exists in the database
|
||||
- Creates a new database entry if it doesn't exist
|
||||
- Adds the vector store to the registry
|
||||
|
||||
Args:
|
||||
response: The response from litellm.aingest()
|
||||
ingest_options: The ingest options containing vector store config
|
||||
prisma_client: The Prisma database client
|
||||
user_api_key_dict: User API key authentication info
|
||||
"""
|
||||
from litellm.proxy.vector_store_endpoints.management_endpoints import (
|
||||
create_vector_store_in_db,
|
||||
)
|
||||
|
||||
# Handle both dict and object responses
|
||||
if hasattr(response, "get"):
|
||||
vector_store_id = response.get("vector_store_id")
|
||||
elif hasattr(response, "vector_store_id"):
|
||||
vector_store_id = response.vector_store_id
|
||||
else:
|
||||
verbose_proxy_logger.warning(
|
||||
f"Unable to extract vector_store_id from response type: {type(response)}"
|
||||
)
|
||||
return
|
||||
|
||||
if vector_store_id is None or not isinstance(vector_store_id, str):
|
||||
verbose_proxy_logger.warning(
|
||||
"Vector store ID is None or not a string, skipping database save"
|
||||
)
|
||||
return
|
||||
|
||||
vector_store_config = ingest_options.get("vector_store", {})
|
||||
custom_llm_provider = vector_store_config.get("custom_llm_provider")
|
||||
|
||||
# Extract litellm_vector_store_params for custom name and description
|
||||
litellm_vector_store_params = ingest_options.get("litellm_vector_store_params", {})
|
||||
custom_vector_store_name = litellm_vector_store_params.get("vector_store_name")
|
||||
custom_vector_store_description = litellm_vector_store_params.get("vector_store_description")
|
||||
|
||||
# Build file metadata entry using helper
|
||||
file_entry = _build_file_metadata_entry(
|
||||
response=response,
|
||||
file_data=file_data,
|
||||
file_url=file_url,
|
||||
)
|
||||
|
||||
try:
|
||||
# Check if vector store already exists in database
|
||||
existing_vector_store = (
|
||||
await prisma_client.db.litellm_managedvectorstorestable.find_unique(
|
||||
where={"vector_store_id": vector_store_id}
|
||||
)
|
||||
)
|
||||
|
||||
# Only create if it doesn't exist
|
||||
if existing_vector_store is None:
|
||||
verbose_proxy_logger.info(
|
||||
f"Saving newly created vector store {vector_store_id} to database"
|
||||
)
|
||||
|
||||
# Initialize metadata with first file
|
||||
initial_metadata = {
|
||||
"ingested_files": [file_entry]
|
||||
}
|
||||
|
||||
# Use custom name if provided, otherwise default
|
||||
vector_store_name = custom_vector_store_name or f"RAG Vector Store - {vector_store_id[:8]}"
|
||||
vector_store_description = custom_vector_store_description or "Created via RAG ingest endpoint"
|
||||
|
||||
await create_vector_store_in_db(
|
||||
vector_store_id=vector_store_id,
|
||||
custom_llm_provider=custom_llm_provider or "openai",
|
||||
prisma_client=prisma_client,
|
||||
vector_store_name=vector_store_name,
|
||||
vector_store_description=vector_store_description,
|
||||
vector_store_metadata=initial_metadata,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
f"Vector store {vector_store_id} saved to database successfully"
|
||||
)
|
||||
else:
|
||||
verbose_proxy_logger.info(
|
||||
f"Vector store {vector_store_id} already exists, appending file to metadata"
|
||||
)
|
||||
|
||||
# Update existing vector store with new file
|
||||
existing_metadata = existing_vector_store.vector_store_metadata or {}
|
||||
if isinstance(existing_metadata, str):
|
||||
import json
|
||||
existing_metadata = json.loads(existing_metadata)
|
||||
|
||||
ingested_files = existing_metadata.get("ingested_files", [])
|
||||
ingested_files.append(file_entry)
|
||||
existing_metadata["ingested_files"] = ingested_files
|
||||
|
||||
# Update the vector store
|
||||
from litellm.proxy.utils import safe_dumps
|
||||
await prisma_client.db.litellm_managedvectorstorestable.update(
|
||||
where={"vector_store_id": vector_store_id},
|
||||
data={"vector_store_metadata": safe_dumps(existing_metadata)}
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
f"Added file {file_entry.get('filename') or file_entry.get('file_url', 'Unknown')} to vector store {vector_store_id} metadata"
|
||||
)
|
||||
except Exception as db_error:
|
||||
# Log the error but don't fail the request since ingestion succeeded
|
||||
verbose_proxy_logger.exception(
|
||||
f"Failed to save vector store {vector_store_id} to database: {db_error}"
|
||||
)
|
||||
|
||||
|
||||
async def parse_rag_ingest_request(
|
||||
request: Request,
|
||||
) -> Tuple[Dict[str, Any], Optional[Tuple[str, bytes, str]], Optional[str], Optional[str]]:
|
||||
|
|
@ -158,6 +336,7 @@ async def rag_ingest(
|
|||
add_litellm_data_to_request,
|
||||
general_settings,
|
||||
llm_router,
|
||||
prisma_client,
|
||||
proxy_config,
|
||||
version,
|
||||
)
|
||||
|
|
@ -189,6 +368,25 @@ async def rag_ingest(
|
|||
**request_data,
|
||||
)
|
||||
|
||||
# Save vector store to database if it was newly created and prisma_client is available
|
||||
verbose_proxy_logger.debug(
|
||||
f"RAG Ingest - Checking database save conditions: prisma_client={prisma_client is not None}, response={response is not None}, response_type={type(response)}"
|
||||
)
|
||||
|
||||
if prisma_client is not None and response is not None:
|
||||
await _save_vector_store_to_db_from_rag_ingest(
|
||||
response=response,
|
||||
ingest_options=ingest_options,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
file_data=file_data,
|
||||
file_url=file_url,
|
||||
)
|
||||
else:
|
||||
verbose_proxy_logger.warning(
|
||||
f"Skipping database save: prisma_client={prisma_client is not None}, response={response is not None}"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -133,6 +133,112 @@ async def _resolve_embedding_config_from_db(
|
|||
return None
|
||||
|
||||
|
||||
########################################################
|
||||
# Helper Functions
|
||||
########################################################
|
||||
async def create_vector_store_in_db(
|
||||
vector_store_id: str,
|
||||
custom_llm_provider: str,
|
||||
prisma_client,
|
||||
vector_store_name: Optional[str] = None,
|
||||
vector_store_description: Optional[str] = None,
|
||||
vector_store_metadata: Optional[Dict] = None,
|
||||
litellm_params: Optional[Dict] = None,
|
||||
litellm_credential_name: Optional[str] = None,
|
||||
) -> LiteLLM_ManagedVectorStore:
|
||||
"""
|
||||
Helper function to create a vector store in the database.
|
||||
|
||||
This function handles:
|
||||
- Checking if vector store already exists
|
||||
- Creating the vector store in the database
|
||||
- Adding it to the vector store registry
|
||||
|
||||
Returns:
|
||||
LiteLLM_ManagedVectorStore: The created vector store object
|
||||
|
||||
Raises:
|
||||
HTTPException: If vector store already exists or database error occurs
|
||||
"""
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail="Database not connected")
|
||||
|
||||
# Check if vector store already exists
|
||||
existing_vector_store = (
|
||||
await prisma_client.db.litellm_managedvectorstorestable.find_unique(
|
||||
where={"vector_store_id": vector_store_id}
|
||||
)
|
||||
)
|
||||
if existing_vector_store is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Vector store with ID {vector_store_id} already exists",
|
||||
)
|
||||
|
||||
# Prepare data for database
|
||||
data_to_create: Dict[str, Any] = {
|
||||
"vector_store_id": vector_store_id,
|
||||
"custom_llm_provider": custom_llm_provider,
|
||||
}
|
||||
|
||||
if vector_store_name is not None:
|
||||
data_to_create["vector_store_name"] = vector_store_name
|
||||
if vector_store_description is not None:
|
||||
data_to_create["vector_store_description"] = vector_store_description
|
||||
if vector_store_metadata is not None:
|
||||
data_to_create["vector_store_metadata"] = safe_dumps(vector_store_metadata)
|
||||
if litellm_credential_name is not None:
|
||||
data_to_create["litellm_credential_name"] = litellm_credential_name
|
||||
|
||||
# Handle litellm_params - always provide at least an empty dict
|
||||
if litellm_params:
|
||||
# Auto-resolve embedding config if embedding model is provided but config is not
|
||||
embedding_model = litellm_params.get("litellm_embedding_model")
|
||||
if embedding_model and not litellm_params.get("litellm_embedding_config"):
|
||||
resolved_config = await _resolve_embedding_config_from_db(
|
||||
embedding_model=embedding_model,
|
||||
prisma_client=prisma_client
|
||||
)
|
||||
if resolved_config:
|
||||
litellm_params["litellm_embedding_config"] = resolved_config
|
||||
verbose_proxy_logger.info(
|
||||
f"Auto-resolved embedding config for model {embedding_model}"
|
||||
)
|
||||
|
||||
litellm_params_dict = GenericLiteLLMParams(
|
||||
**litellm_params
|
||||
).model_dump(exclude_none=True)
|
||||
data_to_create["litellm_params"] = safe_dumps(litellm_params_dict)
|
||||
else:
|
||||
# Provide empty dict if no litellm_params provided
|
||||
data_to_create["litellm_params"] = safe_dumps({})
|
||||
|
||||
# Create in database
|
||||
_new_vector_store = (
|
||||
await prisma_client.db.litellm_managedvectorstorestable.create(
|
||||
data=data_to_create
|
||||
)
|
||||
)
|
||||
|
||||
new_vector_store: LiteLLM_ManagedVectorStore = LiteLLM_ManagedVectorStore(
|
||||
**_new_vector_store.model_dump()
|
||||
)
|
||||
|
||||
# Add vector store to registry
|
||||
if litellm.vector_store_registry is not None:
|
||||
litellm.vector_store_registry.add_vector_store_to_registry(
|
||||
vector_store=new_vector_store
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
f"Vector store {vector_store_id} created in database successfully"
|
||||
)
|
||||
|
||||
return new_vector_store
|
||||
|
||||
|
||||
########################################################
|
||||
# Management Endpoints
|
||||
########################################################
|
||||
|
|
@ -156,71 +262,34 @@ async def new_vector_store(
|
|||
- vector_store_metadata: Optional[Dict] - Additional metadata for the vector store
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail="Database not connected")
|
||||
|
||||
try:
|
||||
# Check if vector store already exists
|
||||
existing_vector_store = (
|
||||
await prisma_client.db.litellm_managedvectorstorestable.find_unique(
|
||||
where={"vector_store_id": vector_store.get("vector_store_id")}
|
||||
)
|
||||
)
|
||||
if existing_vector_store is not None:
|
||||
vector_store_id = vector_store.get("vector_store_id")
|
||||
custom_llm_provider = vector_store.get("custom_llm_provider")
|
||||
|
||||
if not vector_store_id or not custom_llm_provider:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Vector store with ID {vector_store.get('vector_store_id')} already exists",
|
||||
)
|
||||
|
||||
if vector_store.get("vector_store_metadata") is not None:
|
||||
vector_store["vector_store_metadata"] = safe_dumps(
|
||||
vector_store.get("vector_store_metadata")
|
||||
)
|
||||
|
||||
# Safely handle JSON serialization of litellm_params
|
||||
litellm_params_json: Optional[str] = None
|
||||
_input_litellm_params: dict = vector_store.get("litellm_params", {}) or {}
|
||||
if _input_litellm_params is not None:
|
||||
# Auto-resolve embedding config if embedding model is provided but config is not
|
||||
embedding_model = _input_litellm_params.get("litellm_embedding_model")
|
||||
if embedding_model and not _input_litellm_params.get("litellm_embedding_config"):
|
||||
resolved_config = await _resolve_embedding_config_from_db(
|
||||
embedding_model=embedding_model,
|
||||
prisma_client=prisma_client
|
||||
)
|
||||
if resolved_config:
|
||||
_input_litellm_params["litellm_embedding_config"] = resolved_config
|
||||
verbose_proxy_logger.info(
|
||||
f"Auto-resolved embedding config for model {embedding_model}"
|
||||
)
|
||||
|
||||
litellm_params_dict = GenericLiteLLMParams(
|
||||
**_input_litellm_params
|
||||
).model_dump(exclude_none=True)
|
||||
litellm_params_json = safe_dumps(litellm_params_dict)
|
||||
del vector_store["litellm_params"]
|
||||
|
||||
_new_vector_store = (
|
||||
await prisma_client.db.litellm_managedvectorstorestable.create(
|
||||
data={
|
||||
**vector_store,
|
||||
"litellm_params": litellm_params_json,
|
||||
}
|
||||
detail="vector_store_id and custom_llm_provider are required"
|
||||
)
|
||||
|
||||
# Extract and validate metadata
|
||||
metadata = vector_store.get("vector_store_metadata")
|
||||
validated_metadata: Optional[Dict] = None
|
||||
if metadata is not None and isinstance(metadata, dict):
|
||||
validated_metadata = metadata
|
||||
|
||||
new_vector_store = await create_vector_store_in_db(
|
||||
vector_store_id=vector_store_id,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
prisma_client=prisma_client,
|
||||
vector_store_name=vector_store.get("vector_store_name"),
|
||||
vector_store_description=vector_store.get("vector_store_description"),
|
||||
vector_store_metadata=validated_metadata,
|
||||
litellm_params=vector_store.get("litellm_params"),
|
||||
litellm_credential_name=vector_store.get("litellm_credential_name"),
|
||||
)
|
||||
|
||||
new_vector_store: LiteLLM_ManagedVectorStore = LiteLLM_ManagedVectorStore(
|
||||
**_new_vector_store.model_dump()
|
||||
)
|
||||
|
||||
# Add vector store to registry
|
||||
if litellm.vector_store_registry is not None:
|
||||
litellm.vector_store_registry.add_vector_store_to_registry(
|
||||
vector_store=new_vector_store
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Vector store {vector_store.get('vector_store_id')} created successfully",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
// hooks/useDisableShowPrompts.ts
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { getLocalStorageItem } from "@/utils/localStorageUtils";
|
||||
import { LOCAL_STORAGE_EVENT } from "@/utils/localStorageUtils";
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === "disableShowPrompts") {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const onCustom = (e: Event) => {
|
||||
const { key } = (e as CustomEvent).detail;
|
||||
if (key === "disableShowPrompts") {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(LOCAL_STORAGE_EVENT, onCustom);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(LOCAL_STORAGE_EVENT, onCustom);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return getLocalStorageItem("disableShowPrompts") === "true";
|
||||
}
|
||||
|
||||
export function useDisableShowPrompts() {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
|
|
@ -56,8 +56,10 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||
userRole={userRole}
|
||||
premiumUser={premiumUser}
|
||||
proxySettings={undefined}
|
||||
setProxySettings={() => {}}
|
||||
setProxySettings={() => { }}
|
||||
accessToken={accessToken}
|
||||
isDarkMode={false}
|
||||
toggleDarkMode={() => { }}
|
||||
/>
|
||||
<div className="flex flex-1 overflow-auto">
|
||||
<div className="mt-2">
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { jwtDecode } from "jwt-decode";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { ConfigProvider, theme } from "antd";
|
||||
|
||||
function getCookie(name: string) {
|
||||
// Safer cookie read + decoding; handles '=' inside values
|
||||
|
|
@ -131,6 +132,12 @@ export default function CreateKeyPage() {
|
|||
const [showClaudeCodePrompt, setShowClaudeCodePrompt] = useState(false);
|
||||
const [showClaudeCodeModal, setShowClaudeCodeModal] = useState(false);
|
||||
|
||||
// Dark mode state
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const toggleDarkMode = () => {
|
||||
setIsDarkMode(!isDarkMode);
|
||||
};
|
||||
|
||||
const invitation_id = searchParams.get("invitation_id");
|
||||
|
||||
// Get page from URL, default to 'api-keys' if not present
|
||||
|
|
@ -282,7 +289,7 @@ export default function CreateKeyPage() {
|
|||
const nudgesConfig = await getInProductNudgesCall(accessToken);
|
||||
const isUsingClaudeCode = nudgesConfig?.is_claude_code_enabled || false;
|
||||
setIsClaudeCode(isUsingClaudeCode);
|
||||
|
||||
|
||||
// Show Claude Code prompt on login if enabled
|
||||
if (isUsingClaudeCode) {
|
||||
setShowClaudeCodePrompt(true);
|
||||
|
|
@ -362,225 +369,231 @@ export default function CreateKeyPage() {
|
|||
return (
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider accessToken={accessToken}>
|
||||
{invitation_id ? (
|
||||
<UserDashboard
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
premiumUser={premiumUser}
|
||||
teams={teams}
|
||||
keys={keys}
|
||||
setUserRole={setUserRole}
|
||||
userEmail={userEmail}
|
||||
setUserEmail={setUserEmail}
|
||||
setTeams={setTeams}
|
||||
setKeys={setKeys}
|
||||
organizations={organizations}
|
||||
addKey={addKey}
|
||||
createClicked={createClicked}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Navbar
|
||||
<ConfigProvider theme={{
|
||||
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
}}>
|
||||
<ThemeProvider accessToken={accessToken}>
|
||||
{invitation_id ? (
|
||||
<UserDashboard
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
premiumUser={premiumUser}
|
||||
teams={teams}
|
||||
keys={keys}
|
||||
setUserRole={setUserRole}
|
||||
userEmail={userEmail}
|
||||
setProxySettings={setProxySettings}
|
||||
proxySettings={proxySettings}
|
||||
accessToken={accessToken}
|
||||
isPublicPage={false}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
setUserEmail={setUserEmail}
|
||||
setTeams={setTeams}
|
||||
setKeys={setKeys}
|
||||
organizations={organizations}
|
||||
addKey={addKey}
|
||||
createClicked={createClicked}
|
||||
/>
|
||||
<div className="flex flex-1">
|
||||
<div className="mt-2">
|
||||
<SidebarProvider setPage={updatePage} defaultSelectedKey={page} sidebarCollapsed={sidebarCollapsed} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Navbar
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
premiumUser={premiumUser}
|
||||
userEmail={userEmail}
|
||||
setProxySettings={setProxySettings}
|
||||
proxySettings={proxySettings}
|
||||
accessToken={accessToken}
|
||||
isPublicPage={false}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
isDarkMode={isDarkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
/>
|
||||
<div className="flex flex-1">
|
||||
<div className="mt-2">
|
||||
<SidebarProvider setPage={updatePage} defaultSelectedKey={page} sidebarCollapsed={sidebarCollapsed} />
|
||||
</div>
|
||||
|
||||
{page == "api-keys" ? (
|
||||
<UserDashboard
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
premiumUser={premiumUser}
|
||||
teams={teams}
|
||||
keys={keys}
|
||||
setUserRole={setUserRole}
|
||||
userEmail={userEmail}
|
||||
setUserEmail={setUserEmail}
|
||||
setTeams={setTeams}
|
||||
setKeys={setKeys}
|
||||
organizations={organizations}
|
||||
addKey={addKey}
|
||||
createClicked={createClicked}
|
||||
/>
|
||||
) : page == "models" ? (
|
||||
<OldModelDashboard
|
||||
token={token}
|
||||
keys={keys}
|
||||
modelData={modelData}
|
||||
setModelData={setModelData}
|
||||
premiumUser={premiumUser}
|
||||
teams={teams}
|
||||
/>
|
||||
) : page == "llm-playground" ? (
|
||||
<PlaygroundPage />
|
||||
) : page == "users" ? (
|
||||
<ViewUserDashboard
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
token={token}
|
||||
keys={keys}
|
||||
teams={teams}
|
||||
accessToken={accessToken}
|
||||
setKeys={setKeys}
|
||||
/>
|
||||
) : page == "teams" ? (
|
||||
<OldTeams
|
||||
teams={teams}
|
||||
setTeams={setTeams}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
organizations={organizations}
|
||||
premiumUser={premiumUser}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
) : page == "organizations" ? (
|
||||
<Organizations
|
||||
organizations={organizations}
|
||||
setOrganizations={setOrganizations}
|
||||
userModels={userModels}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
) : page == "admin-panel" ? (
|
||||
<AdminPanel
|
||||
setTeams={setTeams}
|
||||
searchParams={searchParams}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
showSSOBanner={showSSOBanner}
|
||||
premiumUser={premiumUser}
|
||||
proxySettings={proxySettings}
|
||||
/>
|
||||
) : page == "api_ref" ? (
|
||||
<APIReferenceView proxySettings={proxySettings} />
|
||||
) : page == "logging-and-alerts" ? (
|
||||
<Settings userID={userID} userRole={userRole} accessToken={accessToken} premiumUser={premiumUser} />
|
||||
) : page == "budgets" ? (
|
||||
<BudgetPanel accessToken={accessToken} />
|
||||
) : page == "guardrails" ? (
|
||||
<GuardrailsPanel accessToken={accessToken} userRole={userRole} />
|
||||
) : page == "policies" ? (
|
||||
<PoliciesPanel accessToken={accessToken} userRole={userRole} />
|
||||
) : page == "agents" ? (
|
||||
<AgentsPanel accessToken={accessToken} userRole={userRole} />
|
||||
) : page == "prompts" ? (
|
||||
<PromptsPanel accessToken={accessToken} userRole={userRole} />
|
||||
) : page == "transform-request" ? (
|
||||
<TransformRequestPanel accessToken={accessToken} />
|
||||
) : page == "router-settings" ? (
|
||||
<GeneralSettings
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
accessToken={accessToken}
|
||||
modelData={modelData}
|
||||
/>
|
||||
) : page == "ui-theme" ? (
|
||||
<UIThemeSettings userID={userID} userRole={userRole} accessToken={accessToken} />
|
||||
) : page == "cost-tracking" ? (
|
||||
<CostTrackingSettings userID={userID} userRole={userRole} accessToken={accessToken} />
|
||||
) : page == "model-hub-table" ? (
|
||||
isAdminRole(userRole) ? (
|
||||
<ModelHubTable
|
||||
accessToken={accessToken}
|
||||
publicPage={false}
|
||||
premiumUser={premiumUser}
|
||||
{page == "api-keys" ? (
|
||||
<UserDashboard
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
premiumUser={premiumUser}
|
||||
teams={teams}
|
||||
keys={keys}
|
||||
setUserRole={setUserRole}
|
||||
userEmail={userEmail}
|
||||
setUserEmail={setUserEmail}
|
||||
setTeams={setTeams}
|
||||
setKeys={setKeys}
|
||||
organizations={organizations}
|
||||
addKey={addKey}
|
||||
createClicked={createClicked}
|
||||
/>
|
||||
) : page == "models" ? (
|
||||
<OldModelDashboard
|
||||
token={token}
|
||||
keys={keys}
|
||||
modelData={modelData}
|
||||
setModelData={setModelData}
|
||||
premiumUser={premiumUser}
|
||||
teams={teams}
|
||||
/>
|
||||
) : page == "llm-playground" ? (
|
||||
<PlaygroundPage />
|
||||
) : page == "users" ? (
|
||||
<ViewUserDashboard
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
token={token}
|
||||
keys={keys}
|
||||
teams={teams}
|
||||
accessToken={accessToken}
|
||||
setKeys={setKeys}
|
||||
/>
|
||||
) : page == "teams" ? (
|
||||
<OldTeams
|
||||
teams={teams}
|
||||
setTeams={setTeams}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
organizations={organizations}
|
||||
premiumUser={premiumUser}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
) : page == "organizations" ? (
|
||||
<Organizations
|
||||
organizations={organizations}
|
||||
setOrganizations={setOrganizations}
|
||||
userModels={userModels}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
) : page == "admin-panel" ? (
|
||||
<AdminPanel
|
||||
setTeams={setTeams}
|
||||
searchParams={searchParams}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
showSSOBanner={showSSOBanner}
|
||||
premiumUser={premiumUser}
|
||||
proxySettings={proxySettings}
|
||||
/>
|
||||
) : page == "api_ref" ? (
|
||||
<APIReferenceView proxySettings={proxySettings} />
|
||||
) : page == "logging-and-alerts" ? (
|
||||
<Settings userID={userID} userRole={userRole} accessToken={accessToken} premiumUser={premiumUser} />
|
||||
) : page == "budgets" ? (
|
||||
<BudgetPanel accessToken={accessToken} />
|
||||
) : page == "guardrails" ? (
|
||||
<GuardrailsPanel accessToken={accessToken} userRole={userRole} />
|
||||
) : page == "policies" ? (
|
||||
<PoliciesPanel accessToken={accessToken} userRole={userRole} />
|
||||
) : page == "agents" ? (
|
||||
<AgentsPanel accessToken={accessToken} userRole={userRole} />
|
||||
) : page == "prompts" ? (
|
||||
<PromptsPanel accessToken={accessToken} userRole={userRole} />
|
||||
) : page == "transform-request" ? (
|
||||
<TransformRequestPanel accessToken={accessToken} />
|
||||
) : page == "router-settings" ? (
|
||||
<GeneralSettings
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
accessToken={accessToken}
|
||||
modelData={modelData}
|
||||
/>
|
||||
) : page == "ui-theme" ? (
|
||||
<UIThemeSettings userID={userID} userRole={userRole} accessToken={accessToken} />
|
||||
) : page == "cost-tracking" ? (
|
||||
<CostTrackingSettings userID={userID} userRole={userRole} accessToken={accessToken} />
|
||||
) : page == "model-hub-table" ? (
|
||||
isAdminRole(userRole) ? (
|
||||
<ModelHubTable
|
||||
accessToken={accessToken}
|
||||
publicPage={false}
|
||||
premiumUser={premiumUser}
|
||||
userRole={userRole}
|
||||
/>
|
||||
) : (
|
||||
<PublicModelHub accessToken={accessToken} isEmbedded={true} />
|
||||
)
|
||||
) : page == "caching" ? (
|
||||
<CacheDashboard
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
token={token}
|
||||
accessToken={accessToken}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
) : page == "pass-through-settings" ? (
|
||||
<PassThroughSettings
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
accessToken={accessToken}
|
||||
modelData={modelData}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
) : page == "logs" ? (
|
||||
<SpendLogsTable
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
token={token}
|
||||
accessToken={accessToken}
|
||||
allTeams={(teams as Team[]) ?? []}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
) : page == "mcp-servers" ? (
|
||||
<MCPServers accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
) : page == "search-tools" ? (
|
||||
<SearchTools accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
) : page == "tag-management" ? (
|
||||
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
) : page == "claude-code-plugins" ? (
|
||||
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
|
||||
) : page == "vector-stores" ? (
|
||||
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
) : page == "new_usage" ? (
|
||||
<NewUsagePage
|
||||
teams={(teams as Team[]) ?? []}
|
||||
organizations={(organizations as Organization[]) ?? []}
|
||||
/>
|
||||
) : (
|
||||
<PublicModelHub accessToken={accessToken} isEmbedded={true} />
|
||||
)
|
||||
) : page == "caching" ? (
|
||||
<CacheDashboard
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
token={token}
|
||||
accessToken={accessToken}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
) : page == "pass-through-settings" ? (
|
||||
<PassThroughSettings
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
accessToken={accessToken}
|
||||
modelData={modelData}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
) : page == "logs" ? (
|
||||
<SpendLogsTable
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
token={token}
|
||||
accessToken={accessToken}
|
||||
allTeams={(teams as Team[]) ?? []}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
) : page == "mcp-servers" ? (
|
||||
<MCPServers accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
) : page == "search-tools" ? (
|
||||
<SearchTools accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
) : page == "tag-management" ? (
|
||||
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
) : page == "claude-code-plugins" ? (
|
||||
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
|
||||
) : page == "vector-stores" ? (
|
||||
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
) : page == "new_usage" ? (
|
||||
<NewUsagePage
|
||||
teams={(teams as Team[]) ?? []}
|
||||
organizations={(organizations as Organization[]) ?? []}
|
||||
/>
|
||||
) : (
|
||||
<Usage
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
token={token}
|
||||
accessToken={accessToken}
|
||||
keys={keys}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
)}
|
||||
<Usage
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
token={token}
|
||||
accessToken={accessToken}
|
||||
keys={keys}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Survey Components */}
|
||||
<SurveyPrompt
|
||||
isVisible={showSurveyPrompt}
|
||||
onOpen={handleOpenSurvey}
|
||||
onDismiss={handleDismissSurveyPrompt}
|
||||
/>
|
||||
<SurveyModal
|
||||
isOpen={showSurveyModal}
|
||||
onClose={handleSurveyModalClose}
|
||||
onComplete={handleSurveyComplete}
|
||||
/>
|
||||
|
||||
{/* Claude Code Components */}
|
||||
<ClaudeCodePrompt
|
||||
isVisible={showClaudeCodePrompt}
|
||||
onOpen={handleOpenClaudeCode}
|
||||
onDismiss={handleDismissClaudeCodePrompt}
|
||||
/>
|
||||
<ClaudeCodeModal
|
||||
isOpen={showClaudeCodeModal}
|
||||
onClose={handleClaudeCodeModalClose}
|
||||
onComplete={handleClaudeCodeComplete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Survey Components */}
|
||||
<SurveyPrompt
|
||||
isVisible={showSurveyPrompt}
|
||||
onOpen={handleOpenSurvey}
|
||||
onDismiss={handleDismissSurveyPrompt}
|
||||
/>
|
||||
<SurveyModal
|
||||
isOpen={showSurveyModal}
|
||||
onClose={handleSurveyModalClose}
|
||||
onComplete={handleSurveyComplete}
|
||||
/>
|
||||
|
||||
{/* Claude Code Components */}
|
||||
<ClaudeCodePrompt
|
||||
isVisible={showClaudeCodePrompt}
|
||||
onOpen={handleOpenClaudeCode}
|
||||
onDismiss={handleDismissClaudeCodePrompt}
|
||||
/>
|
||||
<ClaudeCodeModal
|
||||
isOpen={showClaudeCodeModal}
|
||||
onClose={handleClaudeCodeModalClose}
|
||||
onComplete={handleClaudeCodeComplete}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ThemeProvider>
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
|||
343
ui/litellm-dashboard/src/components/BulkEditUsers.test.tsx
Normal file
343
ui/litellm-dashboard/src/components/BulkEditUsers.test.tsx
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderWithProviders, screen, waitFor } from "../../tests/test-utils";
|
||||
import BulkEditUserModal from "./BulkEditUsers";
|
||||
import { userBulkUpdateUserCall, teamBulkMemberAddCall } from "./networking";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
|
||||
vi.mock("./networking", () => ({
|
||||
userBulkUpdateUserCall: vi.fn(),
|
||||
teamBulkMemberAddCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./user_edit_view", () => ({
|
||||
UserEditView: ({ onSubmit, onCancel }: { onSubmit: (values: any) => void; onCancel: () => void }) => (
|
||||
<div data-testid="user-edit-view">
|
||||
<button onClick={() => onSubmit({ user_role: "admin", max_budget: 100 })}>Submit</button>
|
||||
<button onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockUserBulkUpdateUserCall = vi.mocked(userBulkUpdateUserCall);
|
||||
const mockTeamBulkMemberAddCall = vi.mocked(teamBulkMemberAddCall);
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onCancel: vi.fn(),
|
||||
selectedUsers: [
|
||||
{ user_id: "user1", user_email: "user1@example.com", user_role: "user", max_budget: 50 },
|
||||
{ user_id: "user2", user_email: "user2@example.com", user_role: "admin", max_budget: null },
|
||||
],
|
||||
possibleUIRoles: {
|
||||
admin: { ui_label: "Admin", description: "Administrator role" },
|
||||
user: { ui_label: "User", description: "Regular user role" },
|
||||
},
|
||||
accessToken: "test-token",
|
||||
onSuccess: vi.fn(),
|
||||
teams: [
|
||||
{ team_id: "team1", team_alias: "Team 1" },
|
||||
{ team_id: "team2", team_alias: "Team 2" },
|
||||
],
|
||||
userRole: "Admin",
|
||||
userModels: ["gpt-4", "gpt-3.5-turbo"],
|
||||
allowAllUsers: false,
|
||||
};
|
||||
|
||||
describe("BulkEditUserModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUserBulkUpdateUserCall.mockResolvedValue({
|
||||
results: [],
|
||||
total_requested: 2,
|
||||
successful_updates: 2,
|
||||
failed_updates: 0,
|
||||
});
|
||||
mockTeamBulkMemberAddCall.mockResolvedValue({
|
||||
successful_additions: 2,
|
||||
failed_additions: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render without crashing", () => {
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(`Bulk Edit ${defaultProps.selectedUsers.length} User(s)`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display modal title with correct user count", () => {
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Bulk Edit 2 User(s)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display selected users table when modal is open", () => {
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Selected Users (2):")).toBeInTheDocument();
|
||||
expect(screen.getByText("user1")).toBeInTheDocument();
|
||||
expect(screen.getByText("user2")).toBeInTheDocument();
|
||||
expect(screen.getByText("user1@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("user2@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display user roles in table", () => {
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Admin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display budget information in table", () => {
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("$50")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unlimited")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onCancel when cancel button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} onCancel={onCancel} />);
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: "Cancel" });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
it("should show update all users checkbox when allowAllUsers is true", () => {
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} allowAllUsers={true} />);
|
||||
|
||||
expect(screen.getByRole("checkbox", { name: /update all users/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show update all users checkbox when allowAllUsers is false", () => {
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} allowAllUsers={false} />);
|
||||
|
||||
expect(screen.queryByRole("checkbox", { name: /update all users/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should toggle update all users mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} allowAllUsers={true} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", { name: /update all users/i });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
|
||||
await user.click(checkbox);
|
||||
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(screen.getByText("Bulk Edit All Users")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show warning message when update all users is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} allowAllUsers={true} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", { name: /update all users/i });
|
||||
await user.click(checkbox);
|
||||
|
||||
expect(screen.getByText(/this will apply changes to all users/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide selected users table when update all users is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} allowAllUsers={true} />);
|
||||
|
||||
expect(screen.getByText("Selected Users (2):")).toBeInTheDocument();
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", { name: /update all users/i });
|
||||
await user.click(checkbox);
|
||||
|
||||
expect(screen.queryByText("Selected Users (2):")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display team management section", () => {
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Team Management")).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: /add selected users to teams/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show team budget input when add to teams is checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
|
||||
|
||||
const addToTeamsCheckbox = screen.getByRole("checkbox", { name: /add selected users to teams/i });
|
||||
await user.click(addToTeamsCheckbox);
|
||||
|
||||
expect(screen.getByText("Team Budget (Optional):")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Max budget per user in team")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render UserEditView component", () => {
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("user-edit-view")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error when access token is missing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} accessToken={null} />);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith("Access token not found");
|
||||
});
|
||||
});
|
||||
|
||||
it("should call userBulkUpdateUserCall with correct payload for selected users", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserBulkUpdateUserCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
{ user_role: "admin", max_budget: 100 },
|
||||
["user1", "user2"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should call userBulkUpdateUserCall with allUsers flag when update all users is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} allowAllUsers={true} />);
|
||||
|
||||
const updateAllCheckbox = screen.getByRole("checkbox", { name: /update all users/i });
|
||||
await user.click(updateAllCheckbox);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserBulkUpdateUserCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.objectContaining({ user_role: "admin", max_budget: 100 }),
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("should show success message after successful user update", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUserBulkUpdateUserCall.mockResolvedValue({
|
||||
results: [],
|
||||
total_requested: 2,
|
||||
successful_updates: 2,
|
||||
failed_updates: 0,
|
||||
});
|
||||
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(NotificationsManager.success).toHaveBeenCalledWith("Updated 2 user(s)");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show success message for all users update", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUserBulkUpdateUserCall.mockResolvedValue({
|
||||
results: [],
|
||||
total_requested: 100,
|
||||
successful_updates: 100,
|
||||
failed_updates: 0,
|
||||
});
|
||||
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} allowAllUsers={true} />);
|
||||
|
||||
const updateAllCheckbox = screen.getByRole("checkbox", { name: /update all users/i });
|
||||
await user.click(updateAllCheckbox);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(NotificationsManager.success).toHaveBeenCalledWith("Updated all users (100 total)");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("should show error message when bulk update fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUserBulkUpdateUserCall.mockRejectedValueOnce(new Error("Update failed"));
|
||||
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to perform bulk operations");
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onSuccess and onCancel after successful update", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSuccess = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should truncate long user IDs in table", () => {
|
||||
const longUserId = "a".repeat(30);
|
||||
const propsWithLongId = {
|
||||
...defaultProps,
|
||||
selectedUsers: [{ user_id: longUserId, user_email: "test@example.com", user_role: "user", max_budget: null }],
|
||||
};
|
||||
|
||||
renderWithProviders(<BulkEditUserModal {...propsWithLongId} />);
|
||||
|
||||
expect(screen.getByText(new RegExp(`${longUserId.slice(0, 20)}...`))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display no email text when user email is missing", () => {
|
||||
const propsWithoutEmail = {
|
||||
...defaultProps,
|
||||
selectedUsers: [{ user_id: "user1", user_email: null, user_role: "user", max_budget: null }],
|
||||
};
|
||||
|
||||
renderWithProviders(<BulkEditUserModal {...propsWithoutEmail} />);
|
||||
|
||||
expect(screen.getByText("No email")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display role label from possibleUIRoles when available", () => {
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Admin")).toBeInTheDocument();
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display role key when ui_label is not available", () => {
|
||||
const propsWithoutUIRoles = {
|
||||
...defaultProps,
|
||||
possibleUIRoles: null,
|
||||
};
|
||||
|
||||
renderWithProviders(<BulkEditUserModal {...propsWithoutUIRoles} />);
|
||||
|
||||
expect(screen.getByText("user")).toBeInTheDocument();
|
||||
expect(screen.getByText("admin")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -18,7 +18,7 @@ import NotificationsManager from "./molecules/notifications_manager";
|
|||
const { Text, Title } = Typography;
|
||||
|
||||
interface BulkEditUserModalProps {
|
||||
visible: boolean;
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
selectedUsers: any[];
|
||||
possibleUIRoles: Record<string, Record<string, string>> | null;
|
||||
|
|
@ -31,7 +31,7 @@ interface BulkEditUserModalProps {
|
|||
}
|
||||
|
||||
const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
|
||||
visible,
|
||||
open,
|
||||
onCancel,
|
||||
selectedUsers,
|
||||
possibleUIRoles,
|
||||
|
|
@ -75,7 +75,7 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
|
|||
keys: [],
|
||||
teams: teams || [],
|
||||
}),
|
||||
[teams, visible],
|
||||
[teams, open],
|
||||
);
|
||||
|
||||
const handleSubmit = async (formValues: any) => {
|
||||
|
|
@ -145,7 +145,7 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
|
|||
if (updateAllUsers) {
|
||||
members = null;
|
||||
} else {
|
||||
const members = selectedUsers.map((user) => ({
|
||||
members = selectedUsers.map((user) => ({
|
||||
user_id: user.user_id,
|
||||
role: "user" as const, // Default role for bulk add
|
||||
user_email: user.user_email || null,
|
||||
|
|
@ -214,7 +214,7 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
|
|||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
open={open}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
title={updateAllUsers ? "Bulk Edit All Users" : `Bulk Edit ${selectedUsers.length} User(s)`}
|
||||
|
|
@ -0,0 +1,383 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import KeyLifecycleSettings from "./KeyLifecycleSettings";
|
||||
|
||||
vi.mock("antd", () => {
|
||||
const Option = ({ children, value }: any) => (
|
||||
<option value={value}>{children}</option>
|
||||
);
|
||||
const Select = ({ children, value, onChange, placeholder }: any) => (
|
||||
<select
|
||||
data-testid="select"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
data-placeholder={placeholder}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
Select.Option = Option;
|
||||
return {
|
||||
Select,
|
||||
Tooltip: ({ children, title }: any) => (
|
||||
<div data-testid="tooltip" title={title}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Switch: ({ checked, onChange }: any) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="switch"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
),
|
||||
Divider: () => <hr data-testid="divider" />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
InfoCircleOutlined: () => <span data-testid="info-icon">ℹ</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@tremor/react", () => ({
|
||||
TextInput: ({ value, onValueChange, onChange, placeholder, name, className }: any) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
if (onValueChange) {
|
||||
onValueChange(e.target.value);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<input
|
||||
data-testid={name === "duration" ? "duration-input" : "custom-interval-input"}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
describe("KeyLifecycleSettings", () => {
|
||||
const mockForm = {
|
||||
getFieldValue: vi.fn(),
|
||||
setFieldValue: vi.fn(),
|
||||
setFieldsValue: vi.fn(),
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
form: mockForm,
|
||||
autoRotationEnabled: false,
|
||||
onAutoRotationChange: vi.fn(),
|
||||
rotationInterval: "",
|
||||
onRotationIntervalChange: vi.fn(),
|
||||
isCreateMode: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockForm.getFieldValue.mockReturnValue("");
|
||||
});
|
||||
|
||||
it("should render without crashing", () => {
|
||||
renderWithProviders(<KeyLifecycleSettings {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Key Expiry Settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("Auto-Rotation Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("Key Expiry Settings", () => {
|
||||
it("should render expiry input field", () => {
|
||||
renderWithProviders(<KeyLifecycleSettings {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Expire Key")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("duration-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show correct placeholder in create mode", () => {
|
||||
renderWithProviders(<KeyLifecycleSettings {...defaultProps} isCreateMode={true} />);
|
||||
|
||||
const input = screen.getByTestId("duration-input");
|
||||
expect(input).toHaveAttribute(
|
||||
"placeholder",
|
||||
"e.g., 30d or leave empty to never expire"
|
||||
);
|
||||
});
|
||||
|
||||
it("should show correct placeholder in edit mode", () => {
|
||||
renderWithProviders(<KeyLifecycleSettings {...defaultProps} isCreateMode={false} />);
|
||||
|
||||
const input = screen.getByTestId("duration-input");
|
||||
expect(input).toHaveAttribute("placeholder", "e.g., 30d or -1 to never expire");
|
||||
});
|
||||
|
||||
it("should show correct tooltip in create mode", () => {
|
||||
renderWithProviders(<KeyLifecycleSettings {...defaultProps} isCreateMode={true} />);
|
||||
|
||||
const tooltips = screen.getAllByTestId("tooltip");
|
||||
const expiryTooltip = tooltips.find((tooltip) =>
|
||||
tooltip.getAttribute("title")?.includes("Leave empty to never expire")
|
||||
);
|
||||
expect(expiryTooltip).toBeInTheDocument();
|
||||
expect(expiryTooltip).toHaveAttribute(
|
||||
"title",
|
||||
"Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Leave empty to never expire."
|
||||
);
|
||||
});
|
||||
|
||||
it("should show correct tooltip in edit mode", () => {
|
||||
renderWithProviders(<KeyLifecycleSettings {...defaultProps} isCreateMode={false} />);
|
||||
|
||||
const tooltips = screen.getAllByTestId("tooltip");
|
||||
const expiryTooltip = tooltips.find((tooltip) =>
|
||||
tooltip.getAttribute("title")?.includes("Use -1 to never expire")
|
||||
);
|
||||
expect(expiryTooltip).toBeInTheDocument();
|
||||
expect(expiryTooltip).toHaveAttribute(
|
||||
"title",
|
||||
"Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Use -1 to never expire."
|
||||
);
|
||||
});
|
||||
|
||||
it("should initialize with form value if present", () => {
|
||||
mockForm.getFieldValue.mockReturnValue("30d");
|
||||
renderWithProviders(<KeyLifecycleSettings {...defaultProps} />);
|
||||
|
||||
const input = screen.getByTestId("duration-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("30d");
|
||||
});
|
||||
|
||||
it("should update form using setFieldValue when duration changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<KeyLifecycleSettings {...defaultProps} />);
|
||||
|
||||
const input = screen.getByTestId("duration-input");
|
||||
await user.type(input, "60d");
|
||||
|
||||
expect(mockForm.setFieldValue).toHaveBeenCalledWith("duration", "60d");
|
||||
});
|
||||
|
||||
it("should update form using setFieldsValue when setFieldValue is not available", async () => {
|
||||
const user = userEvent.setup();
|
||||
const formWithoutSetFieldValue = {
|
||||
getFieldValue: vi.fn().mockReturnValue(""),
|
||||
setFieldsValue: vi.fn(),
|
||||
};
|
||||
renderWithProviders(
|
||||
<KeyLifecycleSettings {...defaultProps} form={formWithoutSetFieldValue} />
|
||||
);
|
||||
|
||||
const input = screen.getByTestId("duration-input");
|
||||
await user.type(input, "90d");
|
||||
|
||||
expect(formWithoutSetFieldValue.setFieldsValue).toHaveBeenCalledWith({ duration: "90d" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Auto-Rotation Settings", () => {
|
||||
it("should render auto-rotation switch", () => {
|
||||
renderWithProviders(<KeyLifecycleSettings {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Enable Auto-Rotation")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("switch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show switch as unchecked when autoRotationEnabled is false", () => {
|
||||
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={false} />);
|
||||
|
||||
const switchElement = screen.getByTestId("switch") as HTMLInputElement;
|
||||
expect(switchElement.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("should show switch as checked when autoRotationEnabled is true", () => {
|
||||
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} />);
|
||||
|
||||
const switchElement = screen.getByTestId("switch") as HTMLInputElement;
|
||||
expect(switchElement.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("should call onAutoRotationChange when switch is toggled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAutoRotationChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<KeyLifecycleSettings {...defaultProps} onAutoRotationChange={onAutoRotationChange} />
|
||||
);
|
||||
|
||||
const switchElement = screen.getByTestId("switch");
|
||||
await user.click(switchElement);
|
||||
|
||||
expect(onAutoRotationChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should not show rotation interval section when auto-rotation is disabled", () => {
|
||||
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={false} />);
|
||||
|
||||
expect(screen.queryByText("Rotation Interval")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("select")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show rotation interval section when auto-rotation is enabled", () => {
|
||||
renderWithProviders(
|
||||
<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="30d" />
|
||||
);
|
||||
|
||||
expect(screen.getByText("Rotation Interval")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("select")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show all predefined interval options", () => {
|
||||
renderWithProviders(
|
||||
<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="30d" />
|
||||
);
|
||||
|
||||
expect(screen.getByText("7 days")).toBeInTheDocument();
|
||||
expect(screen.getByText("30 days")).toBeInTheDocument();
|
||||
expect(screen.getByText("90 days")).toBeInTheDocument();
|
||||
expect(screen.getByText("180 days")).toBeInTheDocument();
|
||||
expect(screen.getByText("365 days")).toBeInTheDocument();
|
||||
expect(screen.getByText("Custom interval")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display current rotation interval in select", () => {
|
||||
renderWithProviders(
|
||||
<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="90d" />
|
||||
);
|
||||
|
||||
const select = screen.getByTestId("select") as HTMLSelectElement;
|
||||
expect(select.value).toBe("90d");
|
||||
});
|
||||
|
||||
it("should call onRotationIntervalChange when predefined interval is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRotationIntervalChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<KeyLifecycleSettings
|
||||
{...defaultProps}
|
||||
autoRotationEnabled={true}
|
||||
rotationInterval="7d"
|
||||
onRotationIntervalChange={onRotationIntervalChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByTestId("select");
|
||||
await user.selectOptions(select, "30d");
|
||||
|
||||
expect(onRotationIntervalChange).toHaveBeenCalledWith("30d");
|
||||
});
|
||||
|
||||
it("should show custom input when custom option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="30d" />
|
||||
);
|
||||
|
||||
const select = screen.getByTestId("select");
|
||||
await user.selectOptions(select, "custom");
|
||||
|
||||
expect(screen.getByTestId("custom-interval-input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Supported formats: seconds (s), minutes (m), hours (h), days (d)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide custom input when predefined interval is selected after custom", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRotationIntervalChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<KeyLifecycleSettings
|
||||
{...defaultProps}
|
||||
autoRotationEnabled={true}
|
||||
rotationInterval="custom-value"
|
||||
onRotationIntervalChange={onRotationIntervalChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByTestId("select");
|
||||
await user.selectOptions(select, "7d");
|
||||
|
||||
expect(screen.queryByTestId("custom-interval-input")).not.toBeInTheDocument();
|
||||
expect(onRotationIntervalChange).toHaveBeenCalledWith("7d");
|
||||
});
|
||||
|
||||
it("should call onRotationIntervalChange when custom interval is entered", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRotationIntervalChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<KeyLifecycleSettings
|
||||
{...defaultProps}
|
||||
autoRotationEnabled={true}
|
||||
rotationInterval=""
|
||||
onRotationIntervalChange={onRotationIntervalChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByTestId("select");
|
||||
await user.selectOptions(select, "custom");
|
||||
|
||||
const customInput = screen.getByTestId("custom-interval-input");
|
||||
await user.type(customInput, "14d");
|
||||
|
||||
expect(onRotationIntervalChange).toHaveBeenCalledWith("14d");
|
||||
});
|
||||
|
||||
it("should show info message when auto-rotation is enabled", () => {
|
||||
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"When rotation occurs, you'll receive a notification with the new key. The old key will be deactivated after a brief grace period."
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show info message when auto-rotation is disabled", () => {
|
||||
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"When rotation occurs, you'll receive a notification with the new key. The old key will be deactivated after a brief grace period."
|
||||
)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should initialize with custom interval input visible when custom interval is provided", () => {
|
||||
renderWithProviders(
|
||||
<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="14d" />
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("custom-interval-input")).toBeInTheDocument();
|
||||
const customInput = screen.getByTestId("custom-interval-input") as HTMLInputElement;
|
||||
expect(customInput.value).toBe("14d");
|
||||
});
|
||||
|
||||
it("should show custom option selected when custom interval is provided", () => {
|
||||
renderWithProviders(
|
||||
<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="14d" />
|
||||
);
|
||||
|
||||
const select = screen.getByTestId("select") as HTMLSelectElement;
|
||||
expect(select.value).toBe("custom");
|
||||
});
|
||||
|
||||
it("should not call onRotationIntervalChange when selecting custom option", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRotationIntervalChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<KeyLifecycleSettings
|
||||
{...defaultProps}
|
||||
autoRotationEnabled={true}
|
||||
rotationInterval="30d"
|
||||
onRotationIntervalChange={onRotationIntervalChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByTestId("select");
|
||||
await user.selectOptions(select, "custom");
|
||||
|
||||
expect(onRotationIntervalChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@ interface KeyLifecycleSettingsProps {
|
|||
onAutoRotationChange: (enabled: boolean) => void;
|
||||
rotationInterval: string;
|
||||
onRotationIntervalChange: (interval: string) => void;
|
||||
isCreateMode?: boolean; // If true, shows "leave empty to never expire" instead of "-1 to never expire"
|
||||
}
|
||||
|
||||
const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
|
||||
|
|
@ -19,6 +20,7 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
|
|||
onAutoRotationChange,
|
||||
rotationInterval,
|
||||
onRotationIntervalChange,
|
||||
isCreateMode = false,
|
||||
}) => {
|
||||
// Predefined intervals
|
||||
const predefinedIntervals = ["7d", "30d", "90d", "180d", "365d"];
|
||||
|
|
@ -64,13 +66,19 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
|
|||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center space-x-1">
|
||||
<span>Expire Key</span>
|
||||
<Tooltip title="Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Use -1 to never expire.">
|
||||
<Tooltip
|
||||
title={
|
||||
isCreateMode
|
||||
? "Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Leave empty to never expire."
|
||||
: "Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Use -1 to never expire."
|
||||
}
|
||||
>
|
||||
<InfoCircleOutlined className="text-gray-400 cursor-help text-xs" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<TextInput
|
||||
name="duration"
|
||||
placeholder="e.g., 30d or -1 to never expire"
|
||||
placeholder={isCreateMode ? "e.g., 30d or leave empty to never expire" : "e.g., 30d or -1 to never expire"}
|
||||
className="w-full"
|
||||
value={durationValue}
|
||||
onValueChange={handleDurationChange}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ vi.mock("@/utils/proxyUtils", () => ({
|
|||
let mockUseThemeImpl = () => ({ logoUrl: null as string | null });
|
||||
let mockUseHealthReadinessImpl = () => ({ data: null as any });
|
||||
let mockGetLocalStorageItemImpl = () => null as string | null;
|
||||
let mockUseDisableShowPromptsImpl = () => false;
|
||||
|
||||
vi.mock("@/contexts/ThemeContext", () => ({
|
||||
useTheme: () => mockUseThemeImpl(),
|
||||
|
|
@ -25,7 +26,12 @@ vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness", () => ({
|
|||
useHealthReadiness: () => mockUseHealthReadinessImpl(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({
|
||||
useDisableShowPrompts: () => mockUseDisableShowPromptsImpl(),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/localStorageUtils", () => ({
|
||||
LOCAL_STORAGE_EVENT: "local-storage-change",
|
||||
getLocalStorageItem: () => mockGetLocalStorageItemImpl(),
|
||||
setLocalStorageItem: vi.fn(),
|
||||
removeLocalStorageItem: vi.fn(),
|
||||
|
|
@ -52,6 +58,8 @@ describe("Navbar", () => {
|
|||
setProxySettings: vi.fn(),
|
||||
accessToken: "test-token",
|
||||
isPublicPage: false,
|
||||
isDarkMode: false,
|
||||
toggleDarkMode: vi.fn(),
|
||||
};
|
||||
|
||||
it("should render without crashing", () => {
|
||||
|
|
@ -198,4 +206,11 @@ describe("Navbar", () => {
|
|||
expect(cookieUtils.clearTokenCookies).toHaveBeenCalled();
|
||||
expect(window.location.href).toBe("");
|
||||
});
|
||||
|
||||
it("should not render dark mode toggle slider", () => {
|
||||
renderWithProviders(<Navbar {...defaultProps} />);
|
||||
|
||||
// DO NOT RENDER THIS UNTIL ALL COMPONENTS ARE CONFIRMED TO SUPPORT DARK MODE STYLES. IT IS AN ISSUE IF THIS TEST FAILS.
|
||||
expect(screen.queryByTestId("dark-mode-toggle")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness";
|
||||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { clearTokenCookies } from "@/utils/cookieUtils";
|
||||
|
|
@ -16,8 +17,10 @@ import {
|
|||
MailOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MoonOutlined,
|
||||
SafetyOutlined,
|
||||
SlackOutlined,
|
||||
SunOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
|
|
@ -36,6 +39,8 @@ interface NavbarProps {
|
|||
isPublicPage: boolean;
|
||||
sidebarCollapsed?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
isDarkMode: boolean;
|
||||
toggleDarkMode: () => void;
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({
|
||||
|
|
@ -49,11 +54,13 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
isPublicPage = false,
|
||||
sidebarCollapsed = false,
|
||||
onToggleSidebar,
|
||||
isDarkMode,
|
||||
toggleDarkMode
|
||||
}) => {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
console.log("baseUrl", baseUrl);
|
||||
const [logoutUrl, setLogoutUrl] = useState("");
|
||||
const [disableShowNewBadge, setDisableShowNewBadge] = useState(false);
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
const { logoUrl } = useTheme();
|
||||
const { data: healthData } = useHealthReadiness();
|
||||
const version = healthData?.litellm_version;
|
||||
|
|
@ -152,6 +159,27 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
aria-label="Toggle hide new feature indicators"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center text-sm pt-2 mt-2 border-t border-gray-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="text-gray-500 text-xs">Hide All Prompts</span>
|
||||
<Switch
|
||||
className="ml-auto"
|
||||
size="small"
|
||||
checked={disableShowPrompts}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
setLocalStorageItem("disableShowPrompts", "true");
|
||||
emitLocalStorageChange("disableShowPrompts");
|
||||
} else {
|
||||
removeLocalStorageItem("disableShowPrompts");
|
||||
emitLocalStorageChange("disableShowPrompts");
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle hide all prompts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
|
@ -227,6 +255,15 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
>
|
||||
Star us on GitHub
|
||||
</Button>
|
||||
{/* Dark mode is currently a work in progress. To test, you can change 'false' to 'true' below.
|
||||
Do not set this to true by default until all components are confirmed to support dark mode styles. */}
|
||||
{false && <Switch
|
||||
data-testid="dark-mode-toggle"
|
||||
checked={isDarkMode}
|
||||
onChange={toggleDarkMode}
|
||||
checkedChildren={<MoonOutlined />}
|
||||
unCheckedChildren={<SunOutlined />}
|
||||
/>}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/"
|
||||
target="_blank"
|
||||
|
|
|
|||
|
|
@ -6948,6 +6948,62 @@ export const vectorStoreUpdateCall = async (accessToken: string, formValues: Rec
|
|||
}
|
||||
};
|
||||
|
||||
export const ragIngestCall = async (
|
||||
accessToken: string,
|
||||
file: File,
|
||||
customLlmProvider: string,
|
||||
vectorStoreId?: string,
|
||||
vectorStoreName?: string,
|
||||
vectorStoreDescription?: string
|
||||
): Promise<any> => {
|
||||
try {
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/rag/ingest` : `/rag/ingest`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const ingestOptions: any = {
|
||||
ingest_options: {
|
||||
vector_store: {
|
||||
custom_llm_provider: customLlmProvider,
|
||||
...(vectorStoreId && { vector_store_id: vectorStoreId }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Add litellm_vector_store_params if name or description provided
|
||||
if (vectorStoreName || vectorStoreDescription) {
|
||||
ingestOptions.ingest_options.litellm_vector_store_params = {};
|
||||
if (vectorStoreName) {
|
||||
ingestOptions.ingest_options.litellm_vector_store_params.vector_store_name = vectorStoreName;
|
||||
}
|
||||
if (vectorStoreDescription) {
|
||||
ingestOptions.ingest_options.litellm_vector_store_params.vector_store_description = vectorStoreDescription;
|
||||
}
|
||||
}
|
||||
|
||||
formData.append("request", JSON.stringify(ingestOptions));
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error?.message || error.detail || "Failed to ingest document");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Error ingesting document:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getEmailEventSettings = async (accessToken: string): Promise<EmailEventSettingsResponse> => {
|
||||
try {
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/email/event_settings` : `/email/event_settings`;
|
||||
|
|
|
|||
|
|
@ -321,9 +321,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
formValues.rotation_interval = rotationInterval;
|
||||
}
|
||||
|
||||
// Handle duration field for key expiry
|
||||
if (formValues.duration) {
|
||||
formValues.duration = formValues.duration;
|
||||
// Handle duration field for key expiry - convert empty string to null
|
||||
if (!formValues.duration || formValues.duration.trim() === "") {
|
||||
formValues.duration = null;
|
||||
}
|
||||
|
||||
// Update the formValues with the final metadata
|
||||
|
|
@ -1270,6 +1270,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
onAutoRotationChange={setAutoRotationEnabled}
|
||||
rotationInterval={rotationInterval}
|
||||
onRotationIntervalChange={setRotationInterval}
|
||||
isCreateMode={true}
|
||||
/>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
|
|
|
|||
|
|
@ -962,6 +962,8 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
|||
proxySettings={proxySettings}
|
||||
accessToken={accessToken || null}
|
||||
isPublicPage={true}
|
||||
isDarkMode={false}
|
||||
toggleDarkMode={() => { }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
101
ui/litellm-dashboard/src/components/survey/NudgePrompt.test.tsx
Normal file
101
ui/litellm-dashboard/src/components/survey/NudgePrompt.test.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NudgePrompt } from "./NudgePrompt";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({
|
||||
useDisableShowPrompts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/localStorageUtils", () => ({
|
||||
setLocalStorageItem: vi.fn(),
|
||||
emitLocalStorageChange: vi.fn(),
|
||||
LOCAL_STORAGE_EVENT: "local-storage-change",
|
||||
}));
|
||||
|
||||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
import { emitLocalStorageChange, setLocalStorageItem } from "@/utils/localStorageUtils";
|
||||
|
||||
const mockUseDisableShowPrompts = vi.mocked(useDisableShowPrompts);
|
||||
const mockSetLocalStorageItem = vi.mocked(setLocalStorageItem);
|
||||
const mockEmitLocalStorageChange = vi.mocked(emitLocalStorageChange);
|
||||
|
||||
const defaultProps = {
|
||||
onOpen: vi.fn(),
|
||||
onDismiss: vi.fn(),
|
||||
isVisible: true,
|
||||
title: "Test Title",
|
||||
description: "Test Description",
|
||||
buttonText: "Open Modal",
|
||||
icon: MessageSquare,
|
||||
accentColor: "#3b82f6",
|
||||
};
|
||||
|
||||
describe("NudgePrompt", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseDisableShowPrompts.mockReturnValue(false);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
render(<NudgePrompt {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with all provided props", () => {
|
||||
const { container } = render(<NudgePrompt {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Description")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Open Modal" })).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render when isVisible is false", () => {
|
||||
render(<NudgePrompt {...defaultProps} isVisible={false} />);
|
||||
|
||||
expect(screen.queryByText("Test Title")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render when disableShowPrompts is true", () => {
|
||||
mockUseDisableShowPrompts.mockReturnValue(true);
|
||||
|
||||
render(<NudgePrompt {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByText("Test Title")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display progress bar with correct accent color", () => {
|
||||
const { container } = render(<NudgePrompt {...defaultProps} accentColor="#ff0000" />);
|
||||
|
||||
const progressBar = container.querySelector("div[style*='width']");
|
||||
expect(progressBar).toHaveStyle({ backgroundColor: "#ff0000" });
|
||||
});
|
||||
|
||||
it("should reset progress when isVisible becomes false", () => {
|
||||
const { rerender, container } = render(<NudgePrompt {...defaultProps} />);
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
rerender(<NudgePrompt {...defaultProps} isVisible={false} />);
|
||||
|
||||
rerender(<NudgePrompt {...defaultProps} isVisible={true} />);
|
||||
|
||||
const progressBar = container.querySelector("div[style*='width']");
|
||||
expect(progressBar?.getAttribute("style")).toContain("width: 100%");
|
||||
});
|
||||
|
||||
it("should apply custom button style when provided", () => {
|
||||
const buttonStyle = { backgroundColor: "#custom-color" };
|
||||
render(<NudgePrompt {...defaultProps} buttonStyle={buttonStyle} />);
|
||||
|
||||
const openButton = screen.getByRole("button", { name: "Open Modal" });
|
||||
expect(openButton).toHaveStyle(buttonStyle);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { X, LucideIcon } from "lucide-react";
|
||||
import { X, LucideIcon, Check } from "lucide-react";
|
||||
import { Button } from "antd";
|
||||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
import { setLocalStorageItem, emitLocalStorageChange } from "@/utils/localStorageUtils";
|
||||
|
||||
interface NudgePromptProps {
|
||||
onOpen: () => void;
|
||||
|
|
@ -15,6 +17,7 @@ interface NudgePromptProps {
|
|||
}
|
||||
|
||||
const DISMISS_DURATION = 15000; // 15 seconds
|
||||
const CONFIRMATION_DURATION = 5000; // 5 seconds
|
||||
|
||||
export function NudgePrompt({
|
||||
onOpen,
|
||||
|
|
@ -27,11 +30,14 @@ export function NudgePrompt({
|
|||
accentColor,
|
||||
buttonStyle,
|
||||
}: NudgePromptProps) {
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
const [progress, setProgress] = useState(100);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
setProgress(100);
|
||||
setShowConfirmation(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -49,13 +55,53 @@ export function NudgePrompt({
|
|||
return () => clearInterval(interval);
|
||||
}, [isVisible]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
useEffect(() => {
|
||||
if (showConfirmation) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowConfirmation(false);
|
||||
onDismiss();
|
||||
}, CONFIRMATION_DURATION);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [showConfirmation, onDismiss]);
|
||||
|
||||
const handleDontAskAgain = () => {
|
||||
setLocalStorageItem("disableShowPrompts", "true");
|
||||
emitLocalStorageChange("disableShowPrompts");
|
||||
setShowConfirmation(true);
|
||||
};
|
||||
|
||||
// Show confirmation even if disableShowPrompts is true (since we just set it)
|
||||
if (showConfirmation) {
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-6 right-6 z-40 w-80 bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden transform transition-all duration-300 ease-out ${isVisible ? "translate-y-0 opacity-100 scale-100" : "translate-y-4 opacity-0 scale-95"
|
||||
}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<Check className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-700 font-medium">
|
||||
Got it, we will not ask again. Reactivate this at any time in the User Menu.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't show the prompt if disabled (unless we're showing confirmation)
|
||||
if (!isVisible || disableShowPrompts) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-6 right-6 z-40 w-80 bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden transform transition-all duration-300 ease-out ${
|
||||
isVisible ? "translate-y-0 opacity-100 scale-100" : "translate-y-4 opacity-0 scale-95"
|
||||
}`}
|
||||
className={`fixed bottom-6 right-6 z-40 w-80 bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden transform transition-all duration-300 ease-out ${isVisible ? "translate-y-0 opacity-100 scale-100" : "translate-y-4 opacity-0 scale-95"
|
||||
}`}
|
||||
>
|
||||
{/* Progress bar at top showing time remaining */}
|
||||
<div className="h-1 bg-gray-100 w-full">
|
||||
|
|
@ -81,9 +127,20 @@ export function NudgePrompt({
|
|||
|
||||
<p className="text-sm text-gray-600 mb-3">{description}</p>
|
||||
|
||||
<Button type="primary" block onClick={onOpen} style={buttonStyle}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<Button type="primary" block onClick={onOpen} style={buttonStyle}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
danger
|
||||
block
|
||||
onClick={handleDontAskAgain}
|
||||
className="text-xs"
|
||||
>
|
||||
Don't ask me again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import CreateVectorStore from "./CreateVectorStore";
|
||||
import * as networking from "../networking";
|
||||
|
||||
// Mock the networking module
|
||||
vi.mock("../networking", () => ({
|
||||
ragIngestCall: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock NotificationsManager
|
||||
vi.mock("../molecules/notifications_manager", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
fromBackend: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock vector_store_providers
|
||||
vi.mock("../vector_store_providers", () => ({
|
||||
VectorStoreProviders: {
|
||||
BEDROCK: "Amazon Bedrock",
|
||||
OPENAI: "OpenAI",
|
||||
AZURE_OPENAI: "Azure OpenAI",
|
||||
},
|
||||
vectorStoreProviderMap: {
|
||||
BEDROCK: "bedrock",
|
||||
OPENAI: "openai",
|
||||
AZURE_OPENAI: "azure_openai",
|
||||
},
|
||||
vectorStoreProviderLogoMap: {
|
||||
"Amazon Bedrock": "https://example.com/bedrock.png",
|
||||
"OpenAI": "https://example.com/openai.png",
|
||||
"Azure OpenAI": "https://example.com/azure.png",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("CreateVectorStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the component successfully", () => {
|
||||
render(<CreateVectorStore accessToken="test-token" />);
|
||||
|
||||
expect(screen.getByText("Create Vector Store")).toBeInTheDocument();
|
||||
expect(screen.getByText("Step 1: Upload Documents")).toBeInTheDocument();
|
||||
expect(screen.getByText("Step 2: Select Provider")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display upload area with correct text", () => {
|
||||
render(<CreateVectorStore accessToken="test-token" />);
|
||||
|
||||
expect(screen.getByText("Click or drag files to this area to upload")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Support for single or bulk upload/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have provider selection dropdown", () => {
|
||||
render(<CreateVectorStore accessToken="test-token" />);
|
||||
|
||||
expect(screen.getByText("Provider")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have create button disabled initially when no documents", () => {
|
||||
render(<CreateVectorStore accessToken="test-token" />);
|
||||
|
||||
const createButton = screen.getByRole("button", { name: /Create Vector Store/i });
|
||||
expect(createButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should show uploaded documents table when files are added", async () => {
|
||||
render(<CreateVectorStore accessToken="test-token" />);
|
||||
|
||||
// Create a mock file
|
||||
const file = new File(["test content"], "test.pdf", { type: "application/pdf" });
|
||||
|
||||
// Find the upload input (it's hidden but accessible)
|
||||
const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
if (uploadInput) {
|
||||
fireEvent.change(uploadInput, { target: { files: [file] } });
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Uploaded Documents (1)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call ragIngestCall when create button is clicked", async () => {
|
||||
const mockRagIngestCall = vi.spyOn(networking, "ragIngestCall");
|
||||
mockRagIngestCall.mockResolvedValue({
|
||||
id: "test-id",
|
||||
status: "completed",
|
||||
vector_store_id: "vs_123",
|
||||
file_id: "file_123",
|
||||
});
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
render(<CreateVectorStore accessToken="test-token" onSuccess={onSuccess} />);
|
||||
|
||||
// Create a mock file
|
||||
const file = new File(["test content"], "test.pdf", { type: "application/pdf" });
|
||||
const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
if (uploadInput) {
|
||||
fireEvent.change(uploadInput, { target: { files: [file] } });
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for file to be added
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Uploaded Documents (1)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click create button
|
||||
const createButton = screen.getByRole("button", { name: /Create Vector Store/i });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(createButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRagIngestCall).toHaveBeenCalledWith("test-token", expect.any(File), "bedrock", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("should display success message after successful creation", async () => {
|
||||
const mockRagIngestCall = vi.spyOn(networking, "ragIngestCall");
|
||||
mockRagIngestCall.mockResolvedValue({
|
||||
id: "test-id",
|
||||
status: "completed",
|
||||
vector_store_id: "vs_123",
|
||||
file_id: "file_123",
|
||||
});
|
||||
|
||||
render(<CreateVectorStore accessToken="test-token" />);
|
||||
|
||||
// Create and upload a mock file
|
||||
const file = new File(["test content"], "test.pdf", { type: "application/pdf" });
|
||||
const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
if (uploadInput) {
|
||||
fireEvent.change(uploadInput, { target: { files: [file] } });
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Uploaded Documents (1)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click create button
|
||||
const createButton = screen.getByRole("button", { name: /Create Vector Store/i });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(createButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Vector Store Created Successfully")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
import React, { useState } from "react";
|
||||
import { Card, Title, Text } from "@tremor/react";
|
||||
import { Upload, Button, Select, Form, message, Alert, Tooltip, Input } from "antd";
|
||||
import { InboxOutlined, InfoCircleOutlined } from "@ant-design/icons";
|
||||
import type { UploadProps } from "antd";
|
||||
import { ragIngestCall } from "../networking";
|
||||
import { DocumentUpload, RAGIngestResponse } from "./types";
|
||||
import DocumentsTable from "./DocumentsTable";
|
||||
import {
|
||||
VectorStoreProviders,
|
||||
vectorStoreProviderLogoMap,
|
||||
vectorStoreProviderMap,
|
||||
} from "../vector_store_providers";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
interface CreateVectorStoreProps {
|
||||
accessToken: string | null;
|
||||
onSuccess?: (vectorStoreId: string) => void;
|
||||
}
|
||||
|
||||
const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSuccess }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [documents, setDocuments] = useState<DocumentUpload[]>([]);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>("bedrock");
|
||||
const [vectorStoreName, setVectorStoreName] = useState<string>("");
|
||||
const [vectorStoreDescription, setVectorStoreDescription] = useState<string>("");
|
||||
const [ingestResults, setIngestResults] = useState<RAGIngestResponse[]>([]);
|
||||
|
||||
const uploadProps: UploadProps = {
|
||||
name: "file",
|
||||
multiple: true,
|
||||
accept: ".pdf,.txt,.docx,.md,.doc",
|
||||
beforeUpload: (file) => {
|
||||
const isValidType = [
|
||||
"application/pdf",
|
||||
"text/plain",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword",
|
||||
"text/markdown",
|
||||
].includes(file.type);
|
||||
|
||||
if (!isValidType) {
|
||||
message.error(`${file.name} is not a supported file type. Please upload PDF, TXT, DOCX, or MD files.`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
const isLt50M = file.size / 1024 / 1024 < 50;
|
||||
if (!isLt50M) {
|
||||
message.error(`${file.name} must be smaller than 50MB!`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
const newDoc: DocumentUpload = {
|
||||
uid: file.uid,
|
||||
name: file.name,
|
||||
status: "done",
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
originFileObj: file,
|
||||
};
|
||||
|
||||
setDocuments((prev) => [...prev, newDoc]);
|
||||
return false; // Prevent auto upload
|
||||
},
|
||||
onRemove: (file) => {
|
||||
setDocuments((prev) => prev.filter((doc) => doc.uid !== file.uid));
|
||||
},
|
||||
fileList: documents.map((doc) => ({
|
||||
uid: doc.uid,
|
||||
name: doc.name,
|
||||
status: doc.status,
|
||||
size: doc.size,
|
||||
})),
|
||||
showUploadList: false, // We'll use our custom table
|
||||
};
|
||||
|
||||
const handleRemoveDocument = (uid: string) => {
|
||||
setDocuments((prev) => prev.filter((doc) => doc.uid !== uid));
|
||||
};
|
||||
|
||||
const handleCreateVectorStore = async () => {
|
||||
if (documents.length === 0) {
|
||||
message.warning("Please upload at least one document");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedProvider) {
|
||||
message.warning("Please select a provider");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
message.error("No access token available");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
const results: RAGIngestResponse[] = [];
|
||||
let vectorStoreId: string | undefined;
|
||||
|
||||
try {
|
||||
// Ingest each document
|
||||
for (const doc of documents) {
|
||||
if (!doc.originFileObj) continue;
|
||||
|
||||
// Update document status to uploading
|
||||
setDocuments((prev) =>
|
||||
prev.map((d) => (d.uid === doc.uid ? { ...d, status: "uploading" as const } : d))
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await ragIngestCall(
|
||||
accessToken,
|
||||
doc.originFileObj,
|
||||
selectedProvider,
|
||||
vectorStoreId, // Use the same vector store ID for subsequent uploads
|
||||
vectorStoreName || undefined,
|
||||
vectorStoreDescription || undefined
|
||||
);
|
||||
|
||||
// Store the vector store ID from the first successful ingest
|
||||
if (!vectorStoreId && result.vector_store_id) {
|
||||
vectorStoreId = result.vector_store_id;
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
|
||||
// Update document status to done
|
||||
setDocuments((prev) =>
|
||||
prev.map((d) => (d.uid === doc.uid ? { ...d, status: "done" as const } : d))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Error ingesting ${doc.name}:`, error);
|
||||
// Update document status to error
|
||||
setDocuments((prev) =>
|
||||
prev.map((d) => (d.uid === doc.uid ? { ...d, status: "error" as const } : d))
|
||||
);
|
||||
throw error; // Stop processing on first error
|
||||
}
|
||||
}
|
||||
|
||||
setIngestResults(results);
|
||||
NotificationsManager.success(
|
||||
`Successfully created vector store with ${results.length} document(s). Vector Store ID: ${vectorStoreId}`
|
||||
);
|
||||
|
||||
if (onSuccess && vectorStoreId) {
|
||||
onSuccess(vectorStoreId);
|
||||
}
|
||||
|
||||
// Clear documents after successful creation
|
||||
setTimeout(() => {
|
||||
setDocuments([]);
|
||||
setIngestResults([]);
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
console.error("Error creating vector store:", error);
|
||||
NotificationsManager.fromBackend(`Failed to create vector store: ${error}`);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Title>Create Vector Store</Title>
|
||||
<Text className="text-gray-500">
|
||||
Upload documents and select a provider to create a new vector store with embedded content.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<Card>
|
||||
<div className="mb-4">
|
||||
<Text className="font-medium">Step 1: Upload Documents</Text>
|
||||
<Text className="text-sm text-gray-500 block mt-1">
|
||||
Upload one or more documents (PDF, TXT, DOCX, MD). Maximum file size: 50MB per file.
|
||||
</Text>
|
||||
</div>
|
||||
<Dragger {...uploadProps}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined style={{ fontSize: "48px", color: "#1890ff" }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">Click or drag files to this area to upload</p>
|
||||
<p className="ant-upload-hint">
|
||||
Support for single or bulk upload. Supported formats: PDF, TXT, DOCX, MD
|
||||
</p>
|
||||
</Dragger>
|
||||
</Card>
|
||||
|
||||
{/* Documents Table */}
|
||||
{documents.length > 0 && (
|
||||
<Card>
|
||||
<div className="mb-4">
|
||||
<Text className="font-medium">Uploaded Documents ({documents.length})</Text>
|
||||
</div>
|
||||
<DocumentsTable documents={documents} onRemove={handleRemoveDocument} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Provider Selection and Vector Store Details */}
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text className="font-medium">Step 2: Configure Vector Store</Text>
|
||||
<Text className="text-sm text-gray-500 block mt-1">
|
||||
Choose the provider and optionally provide a name and description for your vector store.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Vector Store Name{" "}
|
||||
<Tooltip title="Optional: Give your vector store a meaningful name">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={vectorStoreName}
|
||||
onChange={(e) => setVectorStoreName(e.target.value)}
|
||||
placeholder="e.g., Product Documentation, Customer Support KB"
|
||||
size="large"
|
||||
className="rounded-md"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Description{" "}
|
||||
<Tooltip title="Optional: Describe what this vector store contains">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Input.TextArea
|
||||
value={vectorStoreDescription}
|
||||
onChange={(e) => setVectorStoreDescription(e.target.value)}
|
||||
placeholder="e.g., Contains all product documentation and user guides"
|
||||
rows={2}
|
||||
size="large"
|
||||
className="rounded-md"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Provider{" "}
|
||||
<Tooltip title="Select the provider for embedding and vector store operations">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
required
|
||||
>
|
||||
<Select
|
||||
value={selectedProvider}
|
||||
onChange={setSelectedProvider}
|
||||
placeholder="Select a provider"
|
||||
size="large"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{Object.entries(VectorStoreProviders).map(([providerEnum, providerDisplayName]) => {
|
||||
return (
|
||||
<Select.Option key={providerEnum} value={vectorStoreProviderMap[providerEnum]}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<img
|
||||
src={vectorStoreProviderLogoMap[providerDisplayName]}
|
||||
alt={`${providerEnum} logo`}
|
||||
className="w-5 h-5"
|
||||
onError={(e) => {
|
||||
// Create a div with provider initial as fallback
|
||||
const target = e.target as HTMLImageElement;
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
const fallbackDiv = document.createElement("div");
|
||||
fallbackDiv.className =
|
||||
"w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center text-xs";
|
||||
fallbackDiv.textContent = providerDisplayName.charAt(0);
|
||||
parent.replaceChild(fallbackDiv, target);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{providerDisplayName}</span>
|
||||
</div>
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleCreateVectorStore}
|
||||
loading={isCreating}
|
||||
disabled={documents.length === 0 || !selectedProvider}
|
||||
>
|
||||
{isCreating ? "Creating Vector Store..." : "Create Vector Store"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Success Message */}
|
||||
{ingestResults.length > 0 && (
|
||||
<Alert
|
||||
message="Vector Store Created Successfully"
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
<strong>Vector Store ID:</strong> {ingestResults[0]?.vector_store_id}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Documents Ingested:</strong> {ingestResults.length}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
type="success"
|
||||
showIcon
|
||||
closable
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateVectorStore;
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import DocumentsTable from "./DocumentsTable";
|
||||
import { DocumentUpload } from "./types";
|
||||
|
||||
// Mock antd message
|
||||
vi.mock("antd", async () => {
|
||||
const actual = await vi.importActual("antd");
|
||||
return {
|
||||
...actual,
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("DocumentsTable", () => {
|
||||
const mockDocuments: DocumentUpload[] = [
|
||||
{
|
||||
uid: "1",
|
||||
name: "test1.pdf",
|
||||
status: "done",
|
||||
size: 1024000,
|
||||
type: "application/pdf",
|
||||
},
|
||||
{
|
||||
uid: "2",
|
||||
name: "test2.txt",
|
||||
status: "uploading",
|
||||
size: 2048000,
|
||||
type: "text/plain",
|
||||
},
|
||||
{
|
||||
uid: "3",
|
||||
name: "test3.docx",
|
||||
status: "error",
|
||||
size: 512000,
|
||||
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
},
|
||||
];
|
||||
|
||||
it("should render the table successfully", () => {
|
||||
const onRemove = vi.fn();
|
||||
render(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
|
||||
|
||||
expect(screen.getByText("test1.pdf")).toBeInTheDocument();
|
||||
expect(screen.getByText("test2.txt")).toBeInTheDocument();
|
||||
expect(screen.getByText("test3.docx")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display correct status badges", () => {
|
||||
const onRemove = vi.fn();
|
||||
render(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
|
||||
|
||||
expect(screen.getByText("Ready")).toBeInTheDocument();
|
||||
expect(screen.getByText("Uploading")).toBeInTheDocument();
|
||||
expect(screen.getByText("Error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display file sizes", () => {
|
||||
const onRemove = vi.fn();
|
||||
render(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
|
||||
|
||||
expect(screen.getByText(/1000.00 KB/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2.00 MB/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/500.00 KB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onRemove when delete button is clicked", () => {
|
||||
const onRemove = vi.fn();
|
||||
render(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
|
||||
|
||||
const deleteButtons = screen.getAllByLabelText(/delete/i);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(deleteButtons[0]);
|
||||
});
|
||||
|
||||
expect(onRemove).toHaveBeenCalledWith("1");
|
||||
});
|
||||
|
||||
it("should show empty state when no documents", () => {
|
||||
const onRemove = vi.fn();
|
||||
render(<DocumentsTable documents={[]} onRemove={onRemove} />);
|
||||
|
||||
expect(screen.getByText(/No documents uploaded yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have action buttons for each document", () => {
|
||||
const onRemove = vi.fn();
|
||||
render(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
|
||||
|
||||
// Each document should have 3 action buttons (view, copy, delete)
|
||||
const viewButtons = screen.getAllByLabelText(/eye/i);
|
||||
const copyButtons = screen.getAllByLabelText(/copy/i);
|
||||
const deleteButtons = screen.getAllByLabelText(/delete/i);
|
||||
|
||||
expect(viewButtons).toHaveLength(3);
|
||||
expect(copyButtons).toHaveLength(3);
|
||||
expect(deleteButtons).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import React from "react";
|
||||
import { Table, Badge, Tooltip, message } from "antd";
|
||||
import { EyeOutlined, CopyOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { DocumentUpload } from "./types";
|
||||
|
||||
interface DocumentsTableProps {
|
||||
documents: DocumentUpload[];
|
||||
onRemove: (uid: string) => void;
|
||||
}
|
||||
|
||||
const DocumentsTable: React.FC<DocumentsTableProps> = ({ documents, onRemove }) => {
|
||||
const handleCopyId = (uid: string) => {
|
||||
navigator.clipboard.writeText(uid);
|
||||
message.success("Document ID copied to clipboard");
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: DocumentUpload["status"]) => {
|
||||
const statusConfig = {
|
||||
uploading: { color: "blue", text: "Uploading" },
|
||||
done: { color: "green", text: "Ready" },
|
||||
error: { color: "red", text: "Error" },
|
||||
removed: { color: "default", text: "Removed" },
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
return <Badge color={config.color} text={config.text} />;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return "-";
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(2)} KB`;
|
||||
return `${(kb / 1024).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: (name: string, record: DocumentUpload) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm">{name}</span>
|
||||
{record.size && <span className="text-xs text-gray-400">({formatFileSize(record.size)})</span>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 150,
|
||||
render: (status: DocumentUpload["status"]) => getStatusBadge(status),
|
||||
},
|
||||
{
|
||||
title: "Actions",
|
||||
key: "actions",
|
||||
width: 120,
|
||||
render: (_: any, record: DocumentUpload) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Tooltip title="View details">
|
||||
<EyeOutlined
|
||||
className="cursor-pointer text-gray-600 hover:text-blue-500"
|
||||
onClick={() => console.log("View", record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Copy ID">
|
||||
<CopyOutlined
|
||||
className="cursor-pointer text-gray-600 hover:text-blue-500"
|
||||
onClick={() => handleCopyId(record.uid)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Remove">
|
||||
<DeleteOutlined
|
||||
className="cursor-pointer text-gray-600 hover:text-red-500"
|
||||
onClick={() => onRemove(record.uid)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={documents}
|
||||
columns={columns}
|
||||
rowKey="uid"
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: "No documents uploaded yet. Upload documents above to get started.",
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsTable;
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import TestVectorStoreTab from "./TestVectorStoreTab";
|
||||
import { VectorStore } from "./types";
|
||||
|
||||
// Mock VectorStoreTester component
|
||||
vi.mock("./VectorStoreTester", () => ({
|
||||
VectorStoreTester: ({ vectorStoreId, accessToken }: { vectorStoreId: string; accessToken: string }) => (
|
||||
<div data-testid="vector-store-tester">
|
||||
<div data-testid="tester-vector-store-id">{vectorStoreId}</div>
|
||||
<div data-testid="tester-access-token">{accessToken}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockVectorStores: VectorStore[] = [
|
||||
{
|
||||
vector_store_id: "vs_123",
|
||||
custom_llm_provider: "openai",
|
||||
vector_store_name: "Test Store 1",
|
||||
vector_store_description: "Description 1",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
vector_store_id: "vs_456",
|
||||
custom_llm_provider: "bedrock",
|
||||
vector_store_name: "Test Store 2",
|
||||
vector_store_description: "Description 2",
|
||||
created_at: "2024-01-02T00:00:00Z",
|
||||
updated_at: "2024-01-02T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
describe("TestVectorStoreTab", () => {
|
||||
it("should render the component successfully", () => {
|
||||
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
|
||||
|
||||
expect(screen.getByText("Select Vector Store")).toBeInTheDocument();
|
||||
expect(screen.getByText("Choose a vector store to test search queries against")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show message when no access token", () => {
|
||||
render(<TestVectorStoreTab accessToken={null} vectorStores={mockVectorStores} />);
|
||||
|
||||
expect(screen.getByText("Access token is required to test vector stores.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show message when no vector stores available", () => {
|
||||
render(<TestVectorStoreTab accessToken="test-token" vectorStores={[]} />);
|
||||
|
||||
expect(screen.getByText("No vector stores available. Create one first to test it.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render VectorStoreTester with first vector store by default", () => {
|
||||
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
|
||||
|
||||
expect(screen.getByTestId("vector-store-tester")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tester-vector-store-id")).toHaveTextContent("vs_123");
|
||||
expect(screen.getByTestId("tester-access-token")).toHaveTextContent("test-token");
|
||||
});
|
||||
|
||||
it("should update VectorStoreTester when selecting different vector store", () => {
|
||||
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
|
||||
|
||||
// Find the select component
|
||||
const selectElement = screen.getByRole("combobox");
|
||||
|
||||
// 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", () => {
|
||||
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
|
||||
|
||||
const selectElement = screen.getByRole("combobox");
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Use getAllByText since the selected value also shows the name
|
||||
expect(screen.getAllByText("Test Store 1").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Test Store 2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import React, { useState } from "react";
|
||||
import { Card, Select, Typography } from "antd";
|
||||
import { VectorStoreTester } from "./VectorStoreTester";
|
||||
import { VectorStore } from "./types";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
interface TestVectorStoreTabProps {
|
||||
accessToken: string | null;
|
||||
vectorStores: VectorStore[];
|
||||
}
|
||||
|
||||
const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, vectorStores }) => {
|
||||
const [selectedVectorStoreId, setSelectedVectorStoreId] = useState<string | undefined>(
|
||||
vectorStores.length > 0 ? vectorStores[0].vector_store_id : undefined
|
||||
);
|
||||
|
||||
if (!accessToken) {
|
||||
return (
|
||||
<Card>
|
||||
<Text type="secondary">Access token is required to test vector stores.</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div 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>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={selectedVectorStoreId}
|
||||
onChange={setSelectedVectorStoreId}
|
||||
placeholder="Select a vector store"
|
||||
size="large"
|
||||
style={{ width: "100%" }}
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{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>
|
||||
</Card>
|
||||
|
||||
{selectedVectorStoreId && (
|
||||
<VectorStoreTester vectorStoreId={selectedVectorStoreId} accessToken={accessToken} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestVectorStoreTab;
|
||||
|
|
@ -130,9 +130,9 @@ describe("VectorStoreTable", () => {
|
|||
expect(screen.getByText("Provider")).toBeInTheDocument();
|
||||
expect(screen.getByText("Created At")).toBeInTheDocument();
|
||||
expect(screen.getByText("Updated At")).toBeInTheDocument();
|
||||
// Check that we have the expected number of header cells (6 data + 1 actions)
|
||||
// Check that we have the expected number of header cells (7 data + 1 actions)
|
||||
const headers = screen.getAllByRole("columnheader");
|
||||
expect(headers).toHaveLength(7);
|
||||
expect(headers).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("should render all vector store rows", () => {
|
||||
|
|
@ -183,7 +183,7 @@ describe("VectorStoreTable", () => {
|
|||
it("should render fallback for missing name", () => {
|
||||
renderComponent();
|
||||
const fallbackElements = screen.getAllByText("-");
|
||||
expect(fallbackElements.length).toBe(2); // One for missing name, one for missing description
|
||||
expect(fallbackElements.length).toBe(3); // One for missing name, one for missing description, one for missing files
|
||||
});
|
||||
|
||||
it("should wrap name in tooltip", () => {
|
||||
|
|
@ -203,7 +203,7 @@ describe("VectorStoreTable", () => {
|
|||
it("should render fallback for missing description", () => {
|
||||
renderComponent();
|
||||
const fallbackElements = screen.getAllByText("-");
|
||||
expect(fallbackElements.length).toBe(2); // One for missing name, one for missing description
|
||||
expect(fallbackElements.length).toBe(3); // One for missing name, one for missing description, one for missing files
|
||||
});
|
||||
|
||||
it("should wrap description in tooltip", () => {
|
||||
|
|
@ -386,7 +386,7 @@ describe("VectorStoreTable", () => {
|
|||
it("should span all columns in empty state", () => {
|
||||
renderComponent({ data: [] });
|
||||
const emptyCell = screen.getByText("No vector stores found").closest("td");
|
||||
expect(emptyCell).toHaveAttribute("colSpan", "7"); // 6 data columns + 1 actions column
|
||||
expect(emptyCell).toHaveAttribute("colSpan", "8"); // 7 data columns + 1 actions column
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -403,7 +403,7 @@ describe("VectorStoreTable", () => {
|
|||
|
||||
renderComponent({ data: minimalData });
|
||||
expect(screen.getByText("minimal")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("-")).toHaveLength(2); // Name and description fallbacks
|
||||
expect(screen.getAllByText("-")).toHaveLength(3); // Name, description, and files fallbacks
|
||||
});
|
||||
|
||||
it("should handle single vector store", () => {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,32 @@ const VectorStoreTable: React.FC<VectorStoreTableProps> = ({ data, onView, onEdi
|
|||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Files",
|
||||
accessorKey: "vector_store_metadata",
|
||||
cell: ({ row }) => {
|
||||
const vectorStore = row.original;
|
||||
const ingestedFiles = vectorStore.vector_store_metadata?.ingested_files || [];
|
||||
|
||||
if (ingestedFiles.length === 0) {
|
||||
return <span className="text-xs text-gray-400">-</span>;
|
||||
}
|
||||
|
||||
const filenames = ingestedFiles
|
||||
.map((file) => file.filename || file.file_url || "Unknown")
|
||||
.join(", ");
|
||||
|
||||
const displayText = ingestedFiles.length === 1
|
||||
? ingestedFiles[0].filename || ingestedFiles[0].file_url || "1 file"
|
||||
: `${ingestedFiles.length} files`;
|
||||
|
||||
return (
|
||||
<Tooltip title={filenames}>
|
||||
<span className="text-xs text-blue-600">{displayText}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Provider",
|
||||
accessorKey: "custom_llm_provider",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Icon, Button as TremorButton, Col, Text, Grid } from "@tremor/react";
|
||||
import { Icon, Button as TremorButton, Col, Text, Grid, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
|
||||
import { RefreshIcon } from "@heroicons/react/outline";
|
||||
import { vectorStoreListCall, vectorStoreDeleteCall, credentialListCall, CredentialItem } from "../networking";
|
||||
import { VectorStore } from "./types";
|
||||
|
|
@ -7,6 +7,8 @@ import VectorStoreTable from "./VectorStoreTable";
|
|||
import VectorStoreForm from "./VectorStoreForm";
|
||||
import DeleteResourceModal from "../common_components/DeleteResourceModal";
|
||||
import VectorStoreInfoView from "./vector_store_info";
|
||||
import CreateVectorStore from "./CreateVectorStore";
|
||||
import TestVectorStoreTab from "./TestVectorStoreTab";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
|
||||
|
|
@ -101,6 +103,12 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
|
|||
fetchVectorStores();
|
||||
};
|
||||
|
||||
const handleVectorStoreCreated = (vectorStoreId: string) => {
|
||||
console.log("Vector store created:", vectorStoreId);
|
||||
fetchVectorStores();
|
||||
// Optionally switch to the manage tab
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchVectorStores();
|
||||
fetchCredentials();
|
||||
|
|
@ -134,18 +142,46 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
|
|||
</div>
|
||||
|
||||
<Text className="mb-4">
|
||||
<p>You can use vector stores to store and retrieve LLM embeddings..</p>
|
||||
<p>You can use vector stores to store and retrieve LLM embeddings.</p>
|
||||
</Text>
|
||||
|
||||
<TremorButton className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
|
||||
+ Add Vector Store
|
||||
</TremorButton>
|
||||
<TabGroup>
|
||||
<TabList className="mb-6">
|
||||
<Tab>Create Vector Store</Tab>
|
||||
<Tab>Manage Vector Stores</Tab>
|
||||
<Tab>Test Vector Store</Tab>
|
||||
</TabList>
|
||||
|
||||
<Grid numItems={1} className="gap-2 pt-2 pb-2 h-[75vh] w-full mt-2">
|
||||
<Col numColSpan={1}>
|
||||
<VectorStoreTable data={vectorStores} onView={handleView} onEdit={handleEdit} onDelete={handleDelete} />
|
||||
</Col>
|
||||
</Grid>
|
||||
<TabPanels>
|
||||
{/* Tab 1: Create Vector Store */}
|
||||
<TabPanel>
|
||||
<CreateVectorStore accessToken={accessToken} onSuccess={handleVectorStoreCreated} />
|
||||
</TabPanel>
|
||||
|
||||
{/* Tab 2: Manage Vector Stores */}
|
||||
<TabPanel>
|
||||
<TremorButton className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
|
||||
+ Add Vector Store
|
||||
</TremorButton>
|
||||
|
||||
<Grid numItems={1} className="gap-2 pt-2 pb-2 w-full mt-2">
|
||||
<Col numColSpan={1}>
|
||||
<VectorStoreTable
|
||||
data={vectorStores}
|
||||
onView={handleView}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
{/* Tab 3: Test Vector Store */}
|
||||
<TabPanel>
|
||||
<TestVectorStoreTab accessToken={accessToken} vectorStores={vectorStores} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
|
||||
{/* Create Vector Store Modal */}
|
||||
<VectorStoreForm
|
||||
|
|
|
|||
|
|
@ -1,9 +1,23 @@
|
|||
export interface IngestedFile {
|
||||
file_id?: string;
|
||||
filename?: string;
|
||||
file_url?: string;
|
||||
ingested_at: string;
|
||||
file_size?: number;
|
||||
content_type?: string;
|
||||
}
|
||||
|
||||
export interface VectorStoreMetadata {
|
||||
ingested_files?: IngestedFile[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface VectorStore {
|
||||
vector_store_id: string;
|
||||
custom_llm_provider: string;
|
||||
vector_store_name?: string;
|
||||
vector_store_description?: string;
|
||||
vector_store_metadata?: Record<string, any>;
|
||||
vector_store_metadata?: VectorStoreMetadata;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
|
|
@ -41,3 +55,32 @@ export interface VectorStoreListResponse {
|
|||
current_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
// Document ingestion types
|
||||
export interface DocumentUpload {
|
||||
uid: string;
|
||||
name: string;
|
||||
status: "uploading" | "done" | "error" | "removed";
|
||||
size?: number;
|
||||
type?: string;
|
||||
originFileObj?: File;
|
||||
}
|
||||
|
||||
export interface RAGIngestRequest {
|
||||
file_url?: string;
|
||||
file_id?: string;
|
||||
ingest_options: {
|
||||
vector_store: {
|
||||
custom_llm_provider: string;
|
||||
vector_store_id?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface RAGIngestResponse {
|
||||
id: string;
|
||||
status: "completed" | "processing" | "failed";
|
||||
vector_store_id: string;
|
||||
file_id: string;
|
||||
error?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
|
|||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@tremor/react";
|
||||
import BulkEditUserModal from "./bulk_edit_user";
|
||||
import BulkEditUserModal from "./BulkEditUsers";
|
||||
import CreateUser from "./create_user_button";
|
||||
import EditUserModal from "./edit_user";
|
||||
import {
|
||||
|
|
@ -286,7 +286,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
|
|||
},
|
||||
handleDelete,
|
||||
handleResetPassword,
|
||||
() => {}, // placeholder function, will be overridden in UserDataTable
|
||||
() => { }, // placeholder function, will be overridden in UserDataTable
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -415,7 +415,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
|
|||
/>
|
||||
|
||||
<BulkEditUserModal
|
||||
visible={isBulkEditModalVisible}
|
||||
open={isBulkEditModalVisible}
|
||||
onCancel={() => setIsBulkEditModalVisible(false)}
|
||||
selectedUsers={selectedUsers}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue