mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
Merge pull request #21516 from BerriAI/litellm_model_settings_modal
[Feature] UI - Models & Endpoints: Add Model Settings Modal
This commit is contained in:
commit
a717f3624d
5 changed files with 687 additions and 55 deletions
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React, { ReactNode } from "react";
|
||||
import { useStoreModelInDB } from "./useStoreModelInDB";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getProxyBaseUrl: vi.fn(() => ""),
|
||||
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
|
||||
}));
|
||||
|
||||
describe("useStoreModelInDB", () => {
|
||||
let queryClient: QueryClient;
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = vi.fn();
|
||||
global.fetch = fetchSpy;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
it("should send correct request body to /config/field/update", async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ message: "Success" }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStoreModelInDB(), { wrapper });
|
||||
|
||||
result.current.mutate({ store_model_in_db: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"/config/field/update",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
field_name: "store_model_in_db",
|
||||
field_value: true,
|
||||
config_type: "general_settings",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle setting store_model_in_db to false", async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ message: "Success" }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStoreModelInDB(), { wrapper });
|
||||
|
||||
result.current.mutate({ store_model_in_db: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"/config/field/update",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
field_name: "store_model_in_db",
|
||||
field_value: false,
|
||||
config_type: "general_settings",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when access token is missing", async () => {
|
||||
vi.spyOn(
|
||||
await import("../useAuthorized"),
|
||||
"default"
|
||||
).mockReturnValue({
|
||||
accessToken: null,
|
||||
userRole: null,
|
||||
userId: null,
|
||||
token: null,
|
||||
userEmail: null,
|
||||
premiumUser: false,
|
||||
disabledPersonalKeyCreation: null,
|
||||
showSSOBanner: false,
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useStoreModelInDB(), { wrapper });
|
||||
|
||||
result.current.mutate({ store_model_in_db: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.error?.message).toBe("Access token is required");
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle API error response", async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ detail: "Unauthorized" }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStoreModelInDB(), { wrapper });
|
||||
|
||||
result.current.mutate({ store_model_in_db: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.error?.message).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("should use fallback error message when API returns empty error", async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStoreModelInDB(), { wrapper });
|
||||
|
||||
result.current.mutate({ store_model_in_db: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.error?.message).toBe("Failed to update model storage settings");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { useMutation, UseMutationResult } from "@tanstack/react-query";
|
||||
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
|
||||
import useAuthorized from "../useAuthorized";
|
||||
|
||||
export interface StoreModelInDBParams {
|
||||
store_model_in_db: boolean;
|
||||
}
|
||||
|
||||
export interface StoreModelInDBResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const performStoreModelInDB = async (
|
||||
accessToken: string,
|
||||
params: StoreModelInDBParams
|
||||
): Promise<StoreModelInDBResponse> => {
|
||||
const proxyBaseUrl = getProxyBaseUrl();
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/config/field/update` : `/config/field/update`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
field_name: "store_model_in_db",
|
||||
field_value: params.store_model_in_db,
|
||||
config_type: "general_settings",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorMessage =
|
||||
errorData?.error?.message || errorData?.message || errorData?.detail || "Failed to update model storage settings";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
};
|
||||
|
||||
export const useStoreModelInDB = (): UseMutationResult<
|
||||
StoreModelInDBResponse,
|
||||
Error,
|
||||
StoreModelInDBParams
|
||||
> => {
|
||||
const { accessToken } = useAuthorized();
|
||||
|
||||
return useMutation<StoreModelInDBResponse, Error, StoreModelInDBParams>({
|
||||
mutationFn: async (params: StoreModelInDBParams) => {
|
||||
if (!accessToken) {
|
||||
throw new Error("Access token is required");
|
||||
}
|
||||
return await performStoreModelInDB(accessToken, params);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -5,10 +5,11 @@ import { Team } from "@/components/key_team_helpers/key_list";
|
|||
import { AllModelsDataTable } from "@/components/model_dashboard/all_models_table";
|
||||
import { columns } from "@/components/molecules/models/columns";
|
||||
import { getDisplayModelName } from "@/components/view_model/model_name_display";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { InfoCircleOutlined, SettingOutlined } from "@ant-design/icons";
|
||||
import { PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { Grid, TabPanel } from "@tremor/react";
|
||||
import { Badge, Select, Skeleton, Space, Typography } from "antd";
|
||||
import { Badge, Button, Select, Skeleton, Space, Typography } from "antd";
|
||||
import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useModelsInfo } from "../../hooks/models/useModels";
|
||||
|
|
@ -51,6 +52,7 @@ const AllModelsTab = ({
|
|||
pageSize: 50,
|
||||
});
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [isModelSettingsModalVisible, setIsModelSettingsModalVisible] = useState(false);
|
||||
|
||||
// Debounce search input
|
||||
const debouncedUpdateSearch = useMemo(
|
||||
|
|
@ -326,62 +328,71 @@ const AllModelsTab = ({
|
|||
<div className="border-b px-6 py-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Search and Filter Controls */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Model Name Search */}
|
||||
<div className="relative w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search model names..."
|
||||
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={modelNameSearch}
|
||||
onChange={(e) => setModelNameSearch(e.target.value)}
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Model Name Search */}
|
||||
<div className="relative w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search model names..."
|
||||
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={modelNameSearch}
|
||||
onChange={(e) => setModelNameSearch(e.target.value)}
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Filter Button */}
|
||||
<button
|
||||
className={`px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2 ${showFilters ? "bg-gray-100" : ""}`}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
Filters
|
||||
</button>
|
||||
|
||||
{/* Reset Filters Button */}
|
||||
<button
|
||||
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Reset Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Button */}
|
||||
<button
|
||||
className={`px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2 ${showFilters ? "bg-gray-100" : ""}`}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
Filters
|
||||
</button>
|
||||
|
||||
{/* Reset Filters Button */}
|
||||
<button
|
||||
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Reset Filters
|
||||
</button>
|
||||
{/* Model Settings Button */}
|
||||
<Button
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => setIsModelSettingsModalVisible(true)}
|
||||
title="Model Settings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Additional Filters */}
|
||||
|
|
@ -505,6 +516,11 @@ const AllModelsTab = ({
|
|||
</div>
|
||||
</div>
|
||||
</Grid>
|
||||
<ModelSettingsModal
|
||||
isVisible={isModelSettingsModalVisible}
|
||||
onCancel={() => setIsModelSettingsModalVisible(false)}
|
||||
onSuccess={() => setIsModelSettingsModalVisible(false)}
|
||||
/>
|
||||
</TabPanel>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,306 @@
|
|||
import { useProxyConfig } from "@/app/(dashboard)/hooks/proxyConfig/useProxyConfig";
|
||||
import { useStoreModelInDB } from "@/app/(dashboard)/hooks/storeModelInDB/useStoreModelInDB";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { parseErrorMessage } from "@/components/shared/errorUtils";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../../../tests/test-utils";
|
||||
import ModelSettingsModal from "./ModelSettingsModal";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/storeModelInDB/useStoreModelInDB");
|
||||
vi.mock("@/app/(dashboard)/hooks/proxyConfig/useProxyConfig");
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
fromBackend: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/shared/errorUtils", () => ({
|
||||
parseErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseStoreModelInDB = vi.mocked(useStoreModelInDB);
|
||||
const mockUseProxyConfig = vi.mocked(useProxyConfig);
|
||||
const mockNotificationsManager = vi.mocked(NotificationsManager);
|
||||
const mockParseErrorMessage = vi.mocked(parseErrorMessage);
|
||||
|
||||
describe("ModelSettingsModal", () => {
|
||||
const mockOnCancel = vi.fn();
|
||||
const mockOnSuccess = vi.fn();
|
||||
const mockMutateAsync = vi.fn();
|
||||
const mockRefetch = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
isVisible: true,
|
||||
onCancel: mockOnCancel,
|
||||
onSuccess: mockOnSuccess,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseStoreModelInDB.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
} as any);
|
||||
mockUseProxyConfig.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
} as any);
|
||||
mockParseErrorMessage.mockImplementation((error: any) => error?.message || String(error));
|
||||
});
|
||||
|
||||
it("should render the modal", () => {
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText("Model Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render form field with initial values", () => {
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
expect(screen.getByText("Store Model in DB")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render cancel and save buttons", () => {
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onCancel when cancel button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: "Cancel" });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onCancel when modal close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: /close/i });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should toggle store model switch", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
|
||||
const switchElement = screen.getByRole("switch");
|
||||
expect(switchElement).not.toBeChecked();
|
||||
|
||||
await user.click(switchElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(switchElement).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it("should submit form with store_model_in_db enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMutateAsync.mockImplementation(async (params, options) => {
|
||||
await Promise.resolve();
|
||||
options?.onSuccess?.();
|
||||
return { message: "Success" };
|
||||
});
|
||||
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
|
||||
const switchElement = screen.getByRole("switch");
|
||||
await user.click(switchElement);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save Settings" });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
{ store_model_in_db: true },
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should submit form with store_model_in_db disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMutateAsync.mockImplementation(async (params, options) => {
|
||||
await Promise.resolve();
|
||||
options?.onSuccess?.();
|
||||
return { message: "Success" };
|
||||
});
|
||||
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save Settings" });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
{ store_model_in_db: false },
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should show success notification and call onSuccess on successful submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMutateAsync.mockImplementation(async (params, options) => {
|
||||
await Promise.resolve();
|
||||
options?.onSuccess?.();
|
||||
return { message: "Success" };
|
||||
});
|
||||
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save Settings" });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Model storage settings updated successfully");
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
expect(mockOnSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should show error notification when submission fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const error = new Error("Network error");
|
||||
mockMutateAsync.mockRejectedValue(error);
|
||||
mockParseErrorMessage.mockReturnValue("Network error");
|
||||
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save Settings" });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to save model storage settings: Network error");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show error notification from onError callback", async () => {
|
||||
const user = userEvent.setup();
|
||||
const error = new Error("Backend error");
|
||||
mockMutateAsync.mockImplementation((params, options) => {
|
||||
options?.onError?.(error);
|
||||
return Promise.reject(error);
|
||||
});
|
||||
mockParseErrorMessage.mockReturnValue("Backend error");
|
||||
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save Settings" });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to save model storage settings: Backend error");
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable cancel button when pending", () => {
|
||||
mockUseStoreModelInDB.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: true,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: "Cancel" });
|
||||
expect(cancelButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should disable cancel button when loading config", () => {
|
||||
mockUseProxyConfig.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
refetch: mockRefetch,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: "Cancel" });
|
||||
expect(cancelButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should show loading state on save button when pending", () => {
|
||||
mockUseStoreModelInDB.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: true,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /Saving/i });
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
expect(saveButton.className).toContain("ant-btn-loading");
|
||||
});
|
||||
|
||||
it("should not render modal when isVisible is false", () => {
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} isVisible={false} />);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call refetch when modal opens", () => {
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should render form with initial values from config data", () => {
|
||||
mockUseProxyConfig.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
field_name: "store_model_in_db",
|
||||
field_type: "bool",
|
||||
field_description: "Store model in DB",
|
||||
field_value: true,
|
||||
stored_in_db: true,
|
||||
field_default_value: false,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
|
||||
const switchElement = screen.getByRole("switch");
|
||||
expect(switchElement).toBeChecked();
|
||||
});
|
||||
|
||||
it("should show skeleton loader when config is loading", () => {
|
||||
mockUseProxyConfig.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
refetch: mockRefetch,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
||||
const skeletons = document.querySelectorAll(".ant-skeleton");
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should not call onSuccess when it is not provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMutateAsync.mockImplementation(async (params, options) => {
|
||||
await Promise.resolve();
|
||||
options?.onSuccess?.();
|
||||
return { message: "Success" };
|
||||
});
|
||||
|
||||
renderWithProviders(<ModelSettingsModal isVisible={true} onCancel={mockOnCancel} />);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save Settings" });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationsManager.success).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import { ConfigType, useProxyConfig } from "@/app/(dashboard)/hooks/proxyConfig/useProxyConfig";
|
||||
import { StoreModelInDBParams, useStoreModelInDB } from "@/app/(dashboard)/hooks/storeModelInDB/useStoreModelInDB";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { parseErrorMessage } from "@/components/shared/errorUtils";
|
||||
import { Button, Form, Modal, Skeleton, Space, Switch, Typography } from "antd";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
|
||||
interface ModelSettingsModalProps {
|
||||
isVisible: boolean;
|
||||
onCancel: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const ModelSettingsModal: React.FC<ModelSettingsModalProps> = ({ isVisible, onCancel, onSuccess }) => {
|
||||
const [form] = Form.useForm();
|
||||
const { mutateAsync, isPending } = useStoreModelInDB();
|
||||
const { data: proxyConfigData, isLoading: isLoadingConfig, refetch } = useProxyConfig(ConfigType.GENERAL_SETTINGS);
|
||||
|
||||
// Refetch config when modal opens to ensure we have the latest values
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
refetch();
|
||||
}
|
||||
}, [isVisible, refetch]);
|
||||
|
||||
// Compute initial values from fetched config data
|
||||
const initialValues = useMemo(() => {
|
||||
if (!proxyConfigData) {
|
||||
return {
|
||||
store_model_in_db: false,
|
||||
};
|
||||
}
|
||||
|
||||
const storeModelField = proxyConfigData.find(field => field.field_name === 'store_model_in_db');
|
||||
|
||||
return {
|
||||
store_model_in_db: storeModelField?.field_value ?? false,
|
||||
};
|
||||
}, [proxyConfigData]);
|
||||
|
||||
const handleFormSubmit = async (formValues: StoreModelInDBParams) => {
|
||||
try {
|
||||
await mutateAsync(formValues, {
|
||||
onSuccess: () => {
|
||||
NotificationsManager.success("Model storage settings updated successfully");
|
||||
refetch();
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
NotificationsManager.fromBackend("Failed to save model storage settings: " + parseErrorMessage(error));
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend("Failed to save model storage settings: " + parseErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<Typography.Title level={5}>Model Settings</Typography.Title>}
|
||||
open={isVisible}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={handleCancel} disabled={isPending || isLoadingConfig}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" loading={isPending} disabled={isLoadingConfig} onClick={() => form.submit()}>
|
||||
{isPending ? "Saving..." : "Save Settings"}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<Form
|
||||
key={proxyConfigData ? JSON.stringify(initialValues) : 'loading'}
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
onFinish={handleFormSubmit}
|
||||
initialValues={initialValues}
|
||||
>
|
||||
<Form.Item
|
||||
label="Store Model in DB"
|
||||
name="store_model_in_db"
|
||||
tooltip={
|
||||
proxyConfigData?.find(f => f.field_name === 'store_model_in_db')?.field_description ||
|
||||
"If enabled, models and config are stored in and loaded from the database."
|
||||
}
|
||||
valuePropName="checked"
|
||||
>
|
||||
{isLoadingConfig ? <Skeleton.Input active block /> : <Switch />}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelSettingsModal;
|
||||
Loading…
Add table
Reference in a new issue