mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
refactor(ui): migrate callback debounce sites to react-pacer with regression tests (#33043)
* refactor(ui): standardize debounce waits behind shared DEBOUNCE_WAIT_MS constant * build(ui): bump @tanstack/react-pacer from 0.2.0 to 0.22.1 * refactor(ui): migrate straightforward value debounces to react-pacer * refactor(ui): migrate callback debounce sites to react-pacer with regression tests * chore(ui): restore trailing newline in eslint-suppressions.json * test(ui): mock all pacer debounce hooks in VirtualKeysTable test * fix(ui): update merged debounce tests for OldTeams to Teams rename
This commit is contained in:
parent
07d2a03dbf
commit
539bc30e04
23 changed files with 708 additions and 131 deletions
|
|
@ -1934,11 +1934,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/user_search_modal.tsx": {
|
||||
"react-hooks/use-memo": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/constants.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
|
|
@ -2104,9 +2099,6 @@
|
|||
"src/components/molecules/filter.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/use-memo": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/molecules/models/columns.test.tsx": {
|
||||
|
|
@ -2168,9 +2160,6 @@
|
|||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 4
|
||||
},
|
||||
"react-hooks/use-memo": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/organization/organization_view.tsx": {
|
||||
|
|
|
|||
8
ui/litellm-dashboard/package-lock.json
generated
8
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -51,7 +51,6 @@
|
|||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/node": "20.19.37",
|
||||
"@types/react": "18.2.48",
|
||||
"@types/react-copy-to-clipboard": "5.0.7",
|
||||
|
|
@ -4109,13 +4108,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
|
|
|
|||
|
|
@ -67,7 +67,6 @@
|
|||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/node": "20.19.37",
|
||||
"@types/react": "18.2.48",
|
||||
"@types/react-copy-to-clipboard": "5.0.7",
|
||||
|
|
@ -95,7 +94,6 @@
|
|||
"js-yaml": "4.2.0",
|
||||
"glob": "13.0.0",
|
||||
"minimatch": "10.2.4",
|
||||
"lodash": "4.18.1",
|
||||
"ws": "8.21.0",
|
||||
"braces": "3.0.3",
|
||||
"axios": "1.13.6",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import AgentCardDiscovery from "./agent_card_discovery";
|
||||
|
|
@ -243,6 +243,54 @@ describe("AgentCardDiscovery", () => {
|
|||
expect(selection.selected_card.capabilities.streaming).toBe(false);
|
||||
});
|
||||
|
||||
it("does not fire discovery before the debounce wait and fires once with the last URL", async () => {
|
||||
mockDiscover.mockResolvedValue({
|
||||
url: "https://last.example.com",
|
||||
agent_card: sampleCard,
|
||||
});
|
||||
renderWithProviders(<AgentCardDiscovery accessToken="tok" onApply={vi.fn()} />);
|
||||
const input = screen.getByPlaceholderText("https://upstream-agent.example.com");
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "https://first.example.com" } });
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(399);
|
||||
});
|
||||
expect(mockDiscover).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "https://last.example.com" } });
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(399);
|
||||
});
|
||||
expect(mockDiscover).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
expect(mockDiscover).toHaveBeenCalledTimes(1);
|
||||
expect(mockDiscover).toHaveBeenCalledWith("tok", "https://last.example.com", undefined);
|
||||
});
|
||||
|
||||
it("fires no discovery when unmounted mid-wait", () => {
|
||||
const { unmount } = renderWithProviders(<AgentCardDiscovery accessToken="tok" onApply={vi.fn()} />);
|
||||
const input = screen.getByPlaceholderText("https://upstream-agent.example.com");
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "https://first.example.com" } });
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
unmount();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(mockDiscover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks discover when no access token is provided", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
renderWithProviders(<AgentCardDiscovery accessToken={null} onApply={vi.fn()} />);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { DiscoveredAgentCard, discoverAgentCardCall } from "@/components/networking";
|
||||
import {
|
||||
ALLOWED_CAPABILITY_KEYS,
|
||||
|
|
@ -22,6 +23,8 @@ import {
|
|||
const { Text, Paragraph } = Typography;
|
||||
const { Panel } = Collapse;
|
||||
|
||||
const DISCOVERY_DEBOUNCE_WAIT_MS = 400;
|
||||
|
||||
export interface DiscoveredAgentCardSelection {
|
||||
/** Full upstream card the proxy fetched, unmodified. */
|
||||
raw_card: DiscoveredAgentCard;
|
||||
|
|
@ -171,6 +174,14 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accessToken, effectiveUrl, isParentDriven, discoveryMode, discoveryParamsKey]);
|
||||
|
||||
const debouncedDiscover = useDebouncedCallback(
|
||||
() => {
|
||||
if (!accessToken || !effectiveUrl.trim()) return;
|
||||
void handleDiscover();
|
||||
},
|
||||
{ wait: DISCOVERY_DEBOUNCE_WAIT_MS },
|
||||
);
|
||||
|
||||
// Auto-discover when the URL (or parent plan) becomes available. Debounce
|
||||
// is applied uniformly so rapid changes from a watched parent form (e.g.
|
||||
// typing into a LangGraph api_base / assistant_id field) don't fire one
|
||||
|
|
@ -186,11 +197,8 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
void handleDiscover();
|
||||
}, 400);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [accessToken, effectiveUrl, handleDiscover]);
|
||||
debouncedDiscover();
|
||||
}, [accessToken, effectiveUrl, handleDiscover, debouncedDiscover]);
|
||||
|
||||
const toggleSkill = (id: string, checked: boolean) => {
|
||||
setSelectedSkillIds((prev) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { renderHook, act } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useChatHistory } from "./useChatHistory";
|
||||
|
||||
describe("useChatHistory", () => {
|
||||
|
|
@ -499,6 +499,80 @@ describe("useChatHistory", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("debounced chatHistory persistence", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should not write chatHistory to sessionStorage before the debounce wait elapses", () => {
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
||||
const { result } = renderHook(() => useChatHistory({ simplified: false }));
|
||||
|
||||
act(() => {
|
||||
result.current.updateTextUI("user", "hello");
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(499);
|
||||
});
|
||||
|
||||
expect(setItemSpy.mock.calls.filter(([key]) => key === "chatHistory")).toHaveLength(0);
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should write chatHistory exactly once with the last value after the wait", () => {
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
||||
const { result } = renderHook(() => useChatHistory({ simplified: false }));
|
||||
|
||||
act(() => {
|
||||
result.current.updateTextUI("user", "h");
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
act(() => {
|
||||
result.current.updateTextUI("user", "i");
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(499);
|
||||
});
|
||||
|
||||
expect(setItemSpy.mock.calls.filter(([key]) => key === "chatHistory")).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
const writes = setItemSpy.mock.calls.filter(([key]) => key === "chatHistory");
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(JSON.parse(writes[0][1])).toEqual([{ role: "user", content: "hi" }]);
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not write chatHistory when unmounted mid-wait", () => {
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
||||
const { result, unmount } = renderHook(() => useChatHistory({ simplified: false }));
|
||||
|
||||
act(() => {
|
||||
result.current.updateTextUI("user", "hello");
|
||||
});
|
||||
unmount();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(setItemSpy.mock.calls.filter(([key]) => key === "chatHistory")).toHaveLength(0);
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("simplified mode session isolation", () => {
|
||||
it("should not hydrate messageTraceId from sessionStorage in simplified mode", () => {
|
||||
sessionStorage.setItem("messageTraceId", "trace-from-playground");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useDebouncer } from "@tanstack/react-pacer/debouncer";
|
||||
import { MessageType, A2ATaskMetadata } from "@/components/chat_ui/types";
|
||||
import { TokenUsage } from "@/components/chat_ui/ResponseMetrics";
|
||||
import { MCPEvent } from "@/components/mcp_tools/types";
|
||||
import { truncateString } from "@/utils/textUtils";
|
||||
|
||||
const CHAT_HISTORY_PERSIST_WAIT_MS = 500;
|
||||
|
||||
export interface UseChatHistoryReturn {
|
||||
// State
|
||||
chatHistory: MessageType[];
|
||||
|
|
@ -64,20 +67,20 @@ export function useChatHistory({ simplified }: { simplified: boolean }): UseChat
|
|||
return saved ? JSON.parse(saved) : true; // Default to API session management
|
||||
});
|
||||
|
||||
// Debounced chatHistory persistence
|
||||
useEffect(() => {
|
||||
if (simplified) return; // Do not persist chat history in simplified (embedded) mode
|
||||
// When chatHistory is empty (e.g. after clearChatHistory removed the key),
|
||||
// don't re-write an empty array back into sessionStorage.
|
||||
if (chatHistory.length === 0) return;
|
||||
const handler = setTimeout(() => {
|
||||
sessionStorage.setItem("chatHistory", JSON.stringify(chatHistory));
|
||||
}, 500); // Debounce by 500ms
|
||||
const persistDebouncer = useDebouncer(
|
||||
(history: MessageType[]) => {
|
||||
sessionStorage.setItem("chatHistory", JSON.stringify(history));
|
||||
},
|
||||
{ wait: CHAT_HISTORY_PERSIST_WAIT_MS },
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [chatHistory, simplified]);
|
||||
useEffect(() => {
|
||||
if (simplified || chatHistory.length === 0) {
|
||||
persistDebouncer.cancel();
|
||||
return;
|
||||
}
|
||||
persistDebouncer.maybeExecute(chatHistory);
|
||||
}, [chatHistory, simplified, persistDebouncer]);
|
||||
|
||||
// messageTraceId/responsesSessionId/useApiSessionManagement persistence
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key";
|
||||
import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking";
|
||||
import Teams from "./Teams";
|
||||
|
|
@ -1142,3 +1142,68 @@ describe("Teams - LIT-2530 organization stays optional for proxy admin with a si
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Teams - search debounce", () => {
|
||||
const emptyTeamList = { teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]);
|
||||
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
|
||||
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
|
||||
vi.mocked(teamListCall).mockResolvedValue(emptyTeamList);
|
||||
mockUseOrganizations.mockReturnValue({ data: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const typeSearch = (value: string) => {
|
||||
fireEvent.change(screen.getByPlaceholderText("Search teams by name or ID..."), { target: { value } });
|
||||
};
|
||||
|
||||
it("fires a single search request with the last value only after the wait elapses", async () => {
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
await act(async () => {});
|
||||
vi.mocked(teamListCall).mockClear();
|
||||
|
||||
act(() => {
|
||||
typeSearch("a");
|
||||
typeSearch("ab");
|
||||
typeSearch("abc");
|
||||
});
|
||||
expect(teamListCall).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(299);
|
||||
});
|
||||
expect(teamListCall).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expect(teamListCall).toHaveBeenCalledTimes(1);
|
||||
expect(teamListCall).toHaveBeenCalledWith("test-token", 1, 10, expect.objectContaining({ search: "abc" }));
|
||||
});
|
||||
|
||||
it("does not fire the search request when unmounted mid-wait", async () => {
|
||||
const { unmount } = renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
await act(async () => {});
|
||||
vi.mocked(teamListCall).mockClear();
|
||||
|
||||
act(() => {
|
||||
typeSearch("abc");
|
||||
});
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(teamListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ import {
|
|||
import type { ColumnsType } from "antd/es/table";
|
||||
import type { SorterResult } from "antd/es/table/interface";
|
||||
import { KeyIcon, LayersIcon, SearchIcon, UsersIcon } from "lucide-react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useDebouncer } from "@tanstack/react-pacer/debouncer";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { AntDLoadingSpinner } from "@/components/ui/AntDLoadingSpinner";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import OrganizationDropdown from "./common_components/OrganizationDropdown";
|
||||
|
|
@ -178,7 +180,6 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
sort_by: "created_at",
|
||||
sort_order: "desc",
|
||||
});
|
||||
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const fetchTeamsV2 = async (
|
||||
|
|
@ -218,6 +219,19 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
}
|
||||
};
|
||||
|
||||
const searchDebouncer = useDebouncer(
|
||||
async (value: string) => {
|
||||
try {
|
||||
setFilters((prev) => ({ ...prev, search: value }));
|
||||
setCurrentPage(1);
|
||||
await fetchTeamsV2({ page: 1, search: value });
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
},
|
||||
{ wait: DEBOUNCE_WAIT_MS },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTeamsV2();
|
||||
}, [accessToken]);
|
||||
|
|
@ -596,17 +610,8 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
};
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
||||
setIsSearching(true);
|
||||
searchDebounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
setFilters((prev) => ({ ...prev, search: value }));
|
||||
setCurrentPage(1);
|
||||
await fetchTeamsV2({ page: 1, search: value });
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
searchDebouncer.maybeExecute(value);
|
||||
};
|
||||
|
||||
const handleFilterChange = async (key: keyof FilterState, value: string) => {
|
||||
|
|
@ -630,7 +635,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
};
|
||||
|
||||
const handleFilterReset = () => {
|
||||
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
||||
searchDebouncer.cancel();
|
||||
setIsSearching(false);
|
||||
const resetFilters: FilterState = {
|
||||
search: "",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ vi.mock("@tanstack/react-pacer/debouncer", async () => {
|
|||
const [value, setValue] = React.useState(initial);
|
||||
return [value, setValue, { cancel: vi.fn(), flush: vi.fn() }];
|
||||
},
|
||||
useDebouncedCallback: (fn: (...args: unknown[]) => void) => fn,
|
||||
useDebouncer: (fn: (...args: unknown[]) => void) => ({ maybeExecute: fn, cancel: vi.fn(), flush: vi.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -56,4 +56,23 @@ describe("FilterInput", () => {
|
|||
|
||||
expect(input.value).toBe("a");
|
||||
});
|
||||
|
||||
it("should not call onChange when unmounted mid-debounce", () => {
|
||||
const onChange = vi.fn();
|
||||
const { unmount } = render(<FilterInput value="" onChange={onChange} placeholder="Search..." />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search...");
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "test" } });
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { cx } from "@/lib/cva.config";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { Input } from "antd";
|
||||
import debounce from "lodash/debounce";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface FilterInputProps {
|
||||
placeholder?: string;
|
||||
|
|
@ -13,8 +14,6 @@ interface FilterInputProps {
|
|||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const DEBOUNCE_DELAY = 300;
|
||||
|
||||
export const FilterInput: React.FC<FilterInputProps> = ({ placeholder, value, onChange, icon: Icon, className }) => {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
|
||||
|
|
@ -22,22 +21,13 @@ export const FilterInput: React.FC<FilterInputProps> = ({ placeholder, value, on
|
|||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const debouncedOnChange = useMemo(() => debounce((val: string) => onChange(val), DEBOUNCE_DELAY), [onChange]);
|
||||
const debouncedOnChange = useDebouncedCallback((val: string) => onChange(val), { wait: DEBOUNCE_WAIT_MS });
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedOnChange.cancel();
|
||||
};
|
||||
}, [debouncedOnChange]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValue(newValue);
|
||||
debouncedOnChange(newValue);
|
||||
},
|
||||
[debouncedOnChange],
|
||||
);
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValue(newValue);
|
||||
debouncedOnChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelSelector from "./ModelSelector";
|
||||
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const openCustomModelInput = () => {
|
||||
const selector = document.querySelector(".ant-select-selector");
|
||||
expect(selector).toBeTruthy();
|
||||
act(() => {
|
||||
fireEvent.mouseDown(selector!);
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("Enter custom model"));
|
||||
});
|
||||
return screen.getByPlaceholderText("Enter custom model name");
|
||||
};
|
||||
|
||||
describe("ModelSelector custom model debounce", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not call onChange before the debounce wait elapses", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ModelSelector accessToken="test-token" onChange={onChange} />);
|
||||
|
||||
const input = openCustomModelInput();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "gpt-4o" } });
|
||||
});
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(499);
|
||||
});
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onChange exactly once with the last typed value after the wait", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ModelSelector accessToken="test-token" onChange={onChange} />);
|
||||
|
||||
const input = openCustomModelInput();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "g" } });
|
||||
fireEvent.change(input, { target: { value: "gp" } });
|
||||
fireEvent.change(input, { target: { value: "gpt-5.2" } });
|
||||
});
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith("gpt-5.2");
|
||||
});
|
||||
|
||||
it("does not call onChange when unmounted mid-wait", () => {
|
||||
const onChange = vi.fn();
|
||||
const { unmount } = render(<ModelSelector accessToken="test-token" onChange={onChange} />);
|
||||
|
||||
const input = openCustomModelInput();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "gpt-4o" } });
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { TextInput, Text } from "@tremor/react";
|
||||
import { Select } from "antd";
|
||||
import { RobotOutlined } from "@ant-design/icons";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
|
||||
const MODEL_SELECT_DEBOUNCE_MS = 500;
|
||||
|
||||
interface ModelSelectorProps {
|
||||
accessToken: string;
|
||||
value?: string;
|
||||
|
|
@ -30,7 +33,6 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
const [selectedModel, setSelectedModel] = useState<string | undefined>(value);
|
||||
const [showCustomModelInput, setShowCustomModelInput] = useState<boolean>(false);
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
const customModelTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedModel(value);
|
||||
|
|
@ -67,19 +69,13 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleCustomModelChange = (value: string) => {
|
||||
// Using setTimeout to create a simple debounce effect
|
||||
if (customModelTimeout.current) {
|
||||
clearTimeout(customModelTimeout.current);
|
||||
}
|
||||
|
||||
customModelTimeout.current = setTimeout(() => {
|
||||
const debouncedSelect = useDebouncedCallback(
|
||||
(value: string) => {
|
||||
setSelectedModel(value);
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
}
|
||||
}, 500); // 500ms delay after typing stops
|
||||
};
|
||||
onChange?.(value);
|
||||
},
|
||||
{ wait: MODEL_SELECT_DEBOUNCE_MS },
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -109,7 +105,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
<TextInput
|
||||
className="mt-2"
|
||||
placeholder="Enter custom model name"
|
||||
onValueChange={handleCustomModelChange}
|
||||
onValueChange={debouncedSelect}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm";
|
||||
import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "./RouterSettingsAccordion";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
getRouterSettingsCall: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("../Settings/RouterSettings/Fallbacks/FallbackSelectionForm", () => ({
|
||||
FallbackSelectionForm: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@tremor/react", () => ({
|
||||
TabGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
Tab: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabPanels: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabPanel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../router_settings/RouterSettingsForm", () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: RouterSettingsFormValue;
|
||||
onChange: (value: RouterSettingsFormValue) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<button onClick={() => onChange({ ...value, selectedStrategy: "least-busy" })}>set-least-busy</button>
|
||||
<button onClick={() => onChange({ ...value, selectedStrategy: "usage-based-routing" })}>set-usage-based</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("RouterSettingsAccordion", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const flushInitialPropagation = async (onChange: ReturnType<typeof vi.fn>) => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
onChange.mockClear();
|
||||
};
|
||||
|
||||
it("debounces propagation and calls onChange once with the last value", async () => {
|
||||
const onChange = vi.fn<(value: RouterSettingsAccordionValue) => void>();
|
||||
render(<RouterSettingsAccordion accessToken="test-token" onChange={onChange} />);
|
||||
await flushInitialPropagation(onChange);
|
||||
|
||||
fireEvent.click(screen.getByText("set-least-busy"));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
fireEvent.click(screen.getByText("set-usage-based"));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(99);
|
||||
});
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange.mock.calls[0][0].router_settings.routing_strategy).toBe("usage-based-routing");
|
||||
});
|
||||
|
||||
it("does not call onChange when unmounted mid-wait", async () => {
|
||||
const onChange = vi.fn<(value: RouterSettingsAccordionValue) => void>();
|
||||
const { unmount } = render(<RouterSettingsAccordion accessToken="test-token" onChange={onChange} />);
|
||||
await flushInitialPropagation(onChange);
|
||||
|
||||
fireEvent.click(screen.getByText("set-least-busy"));
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react";
|
||||
import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { getRouterSettingsCall } from "../networking";
|
||||
import RouterSettingsForm, { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm";
|
||||
import { Fallbacks } from "../Settings/RouterSettings/Fallbacks/AddFallbacks";
|
||||
|
|
@ -35,6 +36,8 @@ export interface RouterSettingsAccordionRef {
|
|||
getValue: () => RouterSettingsAccordionValue;
|
||||
}
|
||||
|
||||
const PROPAGATE_WAIT_MS = 100;
|
||||
|
||||
const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSettingsAccordionProps>(
|
||||
({ accessToken, value, onChange, modelData }, ref) => {
|
||||
const [formValue, setFormValue] = useState<RouterSettingsFormValue>({
|
||||
|
|
@ -304,21 +307,26 @@ const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSet
|
|||
};
|
||||
};
|
||||
|
||||
// Update parent when form values change (with debounce to avoid infinite loops)
|
||||
useEffect(() => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
const debouncedPropagate = useDebouncedCallback(
|
||||
() => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
isInternalUpdateRef.current = true;
|
||||
const finalRouterSettings = buildRouterSettings();
|
||||
onChange({
|
||||
router_settings: finalRouterSettings,
|
||||
});
|
||||
}, 100);
|
||||
},
|
||||
{ wait: PROPAGATE_WAIT_MS },
|
||||
);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
// Update parent when form values change (with debounce to avoid infinite loops)
|
||||
useEffect(() => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
debouncedPropagate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formValue, fallbacks]);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import UserSearchModal from "./user_search_modal";
|
||||
import { userFilterUICall } from "@/components/networking";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
userFilterUICall: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const renderModal = () =>
|
||||
render(<UserSearchModal isVisible onCancel={vi.fn()} onSubmit={vi.fn()} accessToken="sk-test" />);
|
||||
|
||||
const getEmailSearchInput = () => within(screen.getByTestId("member-email-search")).getByRole("combobox");
|
||||
|
||||
describe("UserSearchModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(userFilterUICall).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("debounces the user search and fires exactly once with the last typed value", async () => {
|
||||
renderModal();
|
||||
const input = getEmailSearchInput();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "a" } });
|
||||
fireEvent.change(input, { target: { value: "ab" } });
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DEBOUNCE_WAIT_MS - 1);
|
||||
});
|
||||
expect(userFilterUICall).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(userFilterUICall).toHaveBeenCalledTimes(1);
|
||||
const params = vi.mocked(userFilterUICall).mock.calls[0][1];
|
||||
expect(params.get("user_email")).toBe("abc");
|
||||
});
|
||||
|
||||
it("does not fire the search when unmounted mid-wait", () => {
|
||||
const { unmount } = renderModal();
|
||||
const input = getEmailSearchInput();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DEBOUNCE_WAIT_MS * 2);
|
||||
});
|
||||
|
||||
expect(userFilterUICall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { useState } from "react";
|
||||
import { Modal, Form, Button, Select, Tooltip } from "antd";
|
||||
import { UserAddOutlined } from "@ant-design/icons";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { userFilterUICall } from "@/components/networking";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
interface User {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
|
|
@ -93,9 +94,9 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((text: string, fieldName: "user_email" | "user_id") => fetchUsers(text, fieldName), 300),
|
||||
[],
|
||||
const debouncedSearch = useDebouncedCallback(
|
||||
(text: string, fieldName: "user_email" | "user_id") => fetchUsers(text, fieldName),
|
||||
{ wait: DEBOUNCE_WAIT_MS },
|
||||
);
|
||||
|
||||
const handleSearch = (value: string, fieldName: "user_email" | "user_id"): void => {
|
||||
|
|
|
|||
|
|
@ -547,6 +547,44 @@ describe("FilterComponent", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("cancels a pending debounced search when the component unmounts mid-type", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const mockSearchFn = vi.fn().mockResolvedValue([{ label: "Result", value: "result" }]);
|
||||
|
||||
const options: FilterOption[] = [
|
||||
{
|
||||
name: "model",
|
||||
label: "Model",
|
||||
isSearchable: true,
|
||||
searchFn: mockSearchFn,
|
||||
},
|
||||
];
|
||||
|
||||
const { unmount } = renderWithProviders(
|
||||
<FilterComponent options={options} onApplyFilters={mockOnApplyFilters} onResetFilters={mockOnResetFilters} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Filters" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSearchFn).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
const modelLabel = screen.getByText("Model");
|
||||
const modelSelect = within(modelLabel.closest("div")!).getByRole("combobox");
|
||||
await user.click(modelSelect);
|
||||
await user.type(modelSelect, "test");
|
||||
|
||||
expect(mockSearchFn).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
expect(mockSearchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reset all filter values when reset button is clicked", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderWithProviders(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { FilterIcon } from "@heroicons/react/outline";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { Button, Input, Select } from "antd";
|
||||
import debounce from "lodash/debounce";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export interface FilterOptionCustomComponentProps {
|
||||
|
|
@ -54,8 +55,8 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
|
|||
[key: string]: boolean;
|
||||
}>({});
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce(async (value: string, option: FilterOption) => {
|
||||
const debouncedSearch = useDebouncedCallback(
|
||||
async (value: string, option: FilterOption) => {
|
||||
if (!option.isSearchable || !option.searchFn) return;
|
||||
|
||||
setSearchLoadingMap((prev) => ({ ...prev, [option.name]: true }));
|
||||
|
|
@ -68,8 +69,8 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
|
|||
} finally {
|
||||
setSearchLoadingMap((prev) => ({ ...prev, [option.name]: false }));
|
||||
}
|
||||
}, 300),
|
||||
[],
|
||||
},
|
||||
{ wait: DEBOUNCE_WAIT_MS },
|
||||
);
|
||||
|
||||
// Load initial options for searchable filters
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { act, fireEvent, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
import { userFilterUICall } from "../networking";
|
||||
import CreateKey from "./create_key_button";
|
||||
|
||||
const { formMock, setFieldsValueMock, radioGroupValueRef, formStateRef, mockKeyCreateCall, teamDropdownTeamsRef } =
|
||||
|
|
@ -134,14 +135,16 @@ vi.mock("antd", () => {
|
|||
const Select = ({
|
||||
children,
|
||||
onChange,
|
||||
onSearch,
|
||||
options,
|
||||
...props
|
||||
}: {
|
||||
children?: any;
|
||||
onChange?: (value: string) => void;
|
||||
onSearch?: (value: string) => void;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
}) =>
|
||||
React.createElement(
|
||||
}) => {
|
||||
const select = React.createElement(
|
||||
"select",
|
||||
{
|
||||
...props,
|
||||
|
|
@ -151,6 +154,21 @@ vi.mock("antd", () => {
|
|||
options?.map((opt: any) => React.createElement("option", { key: opt.value, value: opt.value }, opt.label)),
|
||||
);
|
||||
|
||||
if (!onSearch) {
|
||||
return select;
|
||||
}
|
||||
|
||||
return React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement("input", {
|
||||
"data-testid": "select-search-input",
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => onSearch(event.target.value),
|
||||
}),
|
||||
select,
|
||||
);
|
||||
};
|
||||
|
||||
Select.Option = ({ children, ...props }: { children?: any }) => React.createElement("option", props, children);
|
||||
|
||||
const Input = (props: any) => React.createElement("input", props);
|
||||
|
|
@ -641,6 +659,80 @@ describe("CreateKey", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("user search debounce", () => {
|
||||
const mockUserFilterUICall = vi.mocked(userFilterUICall);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const renderUserSearch = () => {
|
||||
const view = renderWithProviders(
|
||||
<CreateKey {...defaultProps} autoOpenCreate={true} prefillData={{ owned_by: "another_user" }} />,
|
||||
);
|
||||
return { input: screen.getByTestId("select-search-input"), unmount: view.unmount };
|
||||
};
|
||||
|
||||
it("should not fire the search before the wait elapses", () => {
|
||||
const { input } = renderUserSearch();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "alice" } });
|
||||
});
|
||||
|
||||
expect(mockUserFilterUICall).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(299);
|
||||
});
|
||||
|
||||
expect(mockUserFilterUICall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fire exactly one search carrying the last value after the wait", async () => {
|
||||
const { input } = renderUserSearch();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "a" } });
|
||||
vi.advanceTimersByTime(100);
|
||||
fireEvent.change(input, { target: { value: "al" } });
|
||||
vi.advanceTimersByTime(100);
|
||||
fireEvent.change(input, { target: { value: "alice" } });
|
||||
});
|
||||
|
||||
expect(mockUserFilterUICall).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(mockUserFilterUICall).toHaveBeenCalledTimes(1);
|
||||
const params = mockUserFilterUICall.mock.calls[0][1] as URLSearchParams;
|
||||
expect(params.get("user_email")).toBe("alice");
|
||||
});
|
||||
|
||||
it("should fire nothing when unmounted mid-wait", () => {
|
||||
const { input, unmount } = renderUserSearch();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "alice" } });
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(mockUserFilterUICall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tags dropdown", () => {
|
||||
it("should populate tags dropdown with options from useTags hook", async () => {
|
||||
renderWithProviders(<CreateKey {...defaultProps} />);
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ import { InfoCircleOutlined } from "@ant-design/icons";
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Accordion, AccordionBody, AccordionHeader, Button, Col, Grid, Text, TextInput, Title } from "@tremor/react";
|
||||
import { Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip, Typography } from "antd";
|
||||
import debounce from "lodash/debounce";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
import { mapDisplayToInternalNames } from "../callback_info_helpers";
|
||||
|
|
@ -688,14 +689,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
}
|
||||
};
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((text: string) => fetchUsers(text), 300),
|
||||
[accessToken],
|
||||
);
|
||||
|
||||
const handleUserSearch = (value: string): void => {
|
||||
debouncedSearch(value);
|
||||
};
|
||||
const handleUserSearch = useDebouncedCallback((text: string) => fetchUsers(text), { wait: DEBOUNCE_WAIT_MS });
|
||||
|
||||
const handleUserSelect = (_value: string, option: UserOption): void => {
|
||||
const selectedUser = option.user;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import moment from "moment";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDebouncer } from "@tanstack/react-pacer/debouncer";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { uiSpendLogsCall } from "../networking";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
|
|
@ -16,15 +18,6 @@ export interface PaginatedResponse {
|
|||
total_is_capped?: boolean;
|
||||
}
|
||||
|
||||
function useDebouncedValue<T>(value: T, delayMs: number): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delayMs]);
|
||||
return [debounced, setDebounced];
|
||||
}
|
||||
|
||||
/** Spend log `model` column (LLM public model name or `search_tool_name` for /search). */
|
||||
export const FILTER_KEYS = {
|
||||
TEAM_ID: "Team ID",
|
||||
|
|
@ -112,7 +105,11 @@ export function useLogFilterLogic({
|
|||
sortOrder?: "asc" | "desc";
|
||||
currentPage?: number;
|
||||
}) {
|
||||
const [debouncedFilters, setDebouncedFilters] = useDebouncedValue(filters, 300);
|
||||
const [debouncedFilters, setDebouncedFilters] = useState(filters);
|
||||
const debouncer = useDebouncer(setDebouncedFilters, { wait: DEBOUNCE_WAIT_MS });
|
||||
useEffect(() => {
|
||||
debouncer.maybeExecute(filters);
|
||||
}, [filters, debouncer]);
|
||||
|
||||
// Live values for dropdown keys, debounced for text keys.
|
||||
const effectiveFilters = useMemo(() => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue