Merge pull request #22157 from BerriAI/litellm_paginated_key_alias
[Feature] UI - Paginated Key Alias Select
This commit is contained in:
commit
719b7fd013
@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React, { ReactNode } from "react";
|
||||
import { useInfiniteKeyAliases } from "./useKeyAliases";
|
||||
import type { PaginatedKeyAliasResponse } from "@/components/networking";
|
||||
|
||||
// Mock networking module
|
||||
vi.mock("@/components/networking", () => ({
|
||||
keyAliasesCall: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock useAuthorized hook
|
||||
const mockUseAuthorized = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => mockUseAuthorized(),
|
||||
}));
|
||||
|
||||
// Mock console methods to avoid noise
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
import { keyAliasesCall } from "@/components/networking";
|
||||
|
||||
const mockKeyAliasesCall = vi.mocked(keyAliasesCall);
|
||||
|
||||
const mockPage1: PaginatedKeyAliasResponse = {
|
||||
aliases: ["alias-1", "alias-2"],
|
||||
total_count: 3,
|
||||
current_page: 1,
|
||||
total_pages: 2,
|
||||
size: 2,
|
||||
};
|
||||
|
||||
const mockPage2: PaginatedKeyAliasResponse = {
|
||||
aliases: ["alias-3"],
|
||||
total_count: 3,
|
||||
current_page: 2,
|
||||
total_pages: 2,
|
||||
size: 2,
|
||||
};
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
|
||||
describe("useInfiniteKeyAliases", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseAuthorized.mockReturnValue({ accessToken: "test-token" });
|
||||
mockKeyAliasesCall.mockResolvedValue(mockPage1);
|
||||
});
|
||||
|
||||
it("should fetch the first page of key aliases", async () => {
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useInfiniteKeyAliases(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, undefined);
|
||||
expect(result.current.data?.pages[0]).toEqual(mockPage1);
|
||||
});
|
||||
|
||||
it("should pass custom size parameter", async () => {
|
||||
const wrapper = createWrapper();
|
||||
renderHook(() => useInfiniteKeyAliases(25), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 25, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass search parameter when provided", async () => {
|
||||
const wrapper = createWrapper();
|
||||
renderHook(() => useInfiniteKeyAliases(50, "my-alias"), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, "my-alias");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not fetch when accessToken is not available", () => {
|
||||
mockUseAuthorized.mockReturnValue({ accessToken: null });
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useInfiniteKeyAliases(), { wrapper });
|
||||
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
expect(mockKeyAliasesCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should expose hasNextPage when more pages are available", async () => {
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useInfiniteKeyAliases(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.hasNextPage).toBe(true);
|
||||
});
|
||||
|
||||
it("should return hasNextPage false when on last page", async () => {
|
||||
const singlePage: PaginatedKeyAliasResponse = {
|
||||
aliases: ["alias-1"],
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
size: 50,
|
||||
};
|
||||
mockKeyAliasesCall.mockResolvedValue(singlePage);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useInfiniteKeyAliases(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.hasNextPage).toBe(false);
|
||||
});
|
||||
|
||||
it("should fetch the next page when fetchNextPage is called", async () => {
|
||||
mockKeyAliasesCall
|
||||
.mockResolvedValueOnce(mockPage1)
|
||||
.mockResolvedValueOnce(mockPage2);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useInfiniteKeyAliases(2), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
result.current.fetchNextPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data?.pages).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 2, 2, undefined);
|
||||
expect(result.current.data?.pages[1]).toEqual(mockPage2);
|
||||
});
|
||||
|
||||
it("should include search in query key so search changes refetch from page 1", async () => {
|
||||
const wrapper = createWrapper();
|
||||
const { result, rerender } = renderHook(
|
||||
({ search }: { search?: string }) => useInfiniteKeyAliases(50, search),
|
||||
{ wrapper, initialProps: { search: undefined } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
mockKeyAliasesCall.mockResolvedValue({
|
||||
aliases: ["search-result"],
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
size: 50,
|
||||
});
|
||||
|
||||
rerender({ search: "search-result" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, "search-result");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,37 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
import { keyAliasesCall, type PaginatedKeyAliasResponse } from "@/components/networking";
|
||||
import useAuthorized from "../useAuthorized";
|
||||
|
||||
const infiniteKeyAliasKeys = createQueryKeys("infiniteKeyAliases");
|
||||
|
||||
export const useInfiniteKeyAliases = (
|
||||
size: number = 50,
|
||||
search?: string,
|
||||
) => {
|
||||
const { accessToken } = useAuthorized();
|
||||
return useInfiniteQuery<PaginatedKeyAliasResponse>({
|
||||
queryKey: infiniteKeyAliasKeys.list({
|
||||
filters: {
|
||||
size,
|
||||
...(search && { search }),
|
||||
},
|
||||
}),
|
||||
queryFn: async ({ pageParam }) => {
|
||||
return await keyAliasesCall(
|
||||
accessToken!,
|
||||
pageParam as number,
|
||||
size,
|
||||
search,
|
||||
);
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.current_page < lastPage.total_pages) {
|
||||
return lastPage.current_page + 1;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
enabled: Boolean(accessToken),
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,255 @@
|
||||
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 { PaginatedKeyAliasSelect } from "./PaginatedKeyAliasSelect";
|
||||
|
||||
const mockFetchNextPage = vi.fn();
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/keys/useKeyAliases", () => ({
|
||||
useInfiniteKeyAliases: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-pacer/debouncer", async () => {
|
||||
const React = await vi.importActual<typeof import("react")>("react");
|
||||
return {
|
||||
useDebouncedState: (initial: string) => {
|
||||
const [value, setValue] = React.useState(initial);
|
||||
return [value, setValue];
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
|
||||
|
||||
const mockUseInfiniteKeyAliases = vi.mocked(useInfiniteKeyAliases);
|
||||
|
||||
const mockPagesWithAliases = {
|
||||
pages: [
|
||||
{
|
||||
aliases: ["alias-1", "alias-2"],
|
||||
total_count: 2,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockEmptyPages = {
|
||||
pages: [{ aliases: [], total_count: 0, current_page: 1, total_pages: 1, size: 50 }],
|
||||
};
|
||||
|
||||
describe("PaginatedKeyAliasSelect", () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
const defaultHookReturn = {
|
||||
data: mockPagesWithAliases,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseInfiniteKeyAliases.mockReturnValue(defaultHookReturn as any);
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
expect(screen.getByText("Select a key alias")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display custom placeholder when provided", () => {
|
||||
renderWithProviders(
|
||||
<PaginatedKeyAliasSelect onChange={mockOnChange} placeholder="Choose alias" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Choose alias")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display alias options when data is loaded", async () => {
|
||||
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole("combobox");
|
||||
await userEvent.click(combobox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("option", { name: "alias-1" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "alias-2" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onChange when user selects an alias", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole("combobox");
|
||||
await user.click(combobox);
|
||||
|
||||
const option = await screen.findByTitle("alias-1");
|
||||
await user.click(option);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnChange).toHaveBeenCalledWith("alias-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show loading state when isLoading is true", () => {
|
||||
mockUseInfiniteKeyAliases.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isLoading: true,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("should pass pageSize to useInfiniteKeyAliases", () => {
|
||||
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} pageSize={25} />);
|
||||
|
||||
expect(mockUseInfiniteKeyAliases).toHaveBeenCalledWith(25, undefined);
|
||||
});
|
||||
|
||||
it("should pass search to useInfiniteKeyAliases when user types", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole("combobox");
|
||||
await user.click(combobox);
|
||||
await user.keyboard("my-alias");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseInfiniteKeyAliases).toHaveBeenCalledWith(50, "my-alias");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have scroll container for infinite loading when hasNextPage is true", async () => {
|
||||
mockUseInfiniteKeyAliases.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
hasNextPage: true,
|
||||
isFetchingNextPage: false,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole("combobox");
|
||||
await userEvent.click(combobox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("option", { name: "alias-1" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const scrollableContainer = document.querySelector(
|
||||
".ant-select-dropdown .rc-virtual-list-holder",
|
||||
);
|
||||
expect(scrollableContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should deduplicate aliases with the same value across pages", async () => {
|
||||
mockUseInfiniteKeyAliases.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
aliases: ["alias-1", "alias-1"],
|
||||
total_count: 2,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole("combobox");
|
||||
await userEvent.click(combobox);
|
||||
|
||||
await waitFor(() => {
|
||||
const options = screen.queryAllByRole("option", { name: "alias-1" });
|
||||
expect(options.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should skip empty aliases", async () => {
|
||||
mockUseInfiniteKeyAliases.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
aliases: ["valid-alias", "", null],
|
||||
total_count: 3,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole("combobox");
|
||||
await userEvent.click(combobox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("option", { name: "valid-alias" })).toBeInTheDocument();
|
||||
const allOptions = screen.queryAllByRole("option");
|
||||
expect(allOptions.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should respect allowClear prop", () => {
|
||||
renderWithProviders(
|
||||
<PaginatedKeyAliasSelect value="alias-1" onChange={mockOnChange} allowClear={false} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should respect disabled prop", () => {
|
||||
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} disabled />);
|
||||
|
||||
const combobox = screen.getByRole("combobox");
|
||||
expect(combobox.closest(".ant-select")).toHaveClass("ant-select-disabled");
|
||||
});
|
||||
|
||||
it("should not call fetchNextPage when hasNextPage is false", async () => {
|
||||
mockUseInfiniteKeyAliases.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
hasNextPage: false,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("option", { name: "alias-1" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show no aliases found when data is empty", async () => {
|
||||
mockUseInfiniteKeyAliases.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
data: mockEmptyPages,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole("combobox");
|
||||
await userEvent.click(combobox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No key aliases found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,106 @@
|
||||
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
|
||||
import { Select } from "antd";
|
||||
import { useMemo, useState, type UIEvent } from "react";
|
||||
|
||||
export interface PaginatedKeyAliasSelectProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
style?: React.CSSProperties;
|
||||
pageSize?: number;
|
||||
allowClear?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SCROLL_THRESHOLD = 0.8;
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
export const PaginatedKeyAliasSelect = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select a key alias",
|
||||
style,
|
||||
pageSize = 50,
|
||||
allowClear = true,
|
||||
disabled = false,
|
||||
}: PaginatedKeyAliasSelectProps) => {
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
|
||||
wait: DEBOUNCE_MS,
|
||||
});
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
} = useInfiniteKeyAliases(pageSize, debouncedSearch || undefined);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const result: { label: string; value: string }[] = [];
|
||||
|
||||
for (const page of data.pages) {
|
||||
for (const alias of page.aliases) {
|
||||
if (!alias || seen.has(alias)) continue;
|
||||
seen.add(alias);
|
||||
result.push({ label: alias, value: alias });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data]);
|
||||
|
||||
const handlePopupScroll = (e: UIEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
const scrollRatio =
|
||||
(target.scrollTop + target.clientHeight) / target.scrollHeight;
|
||||
|
||||
if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchInput(value);
|
||||
setDebouncedSearch(value);
|
||||
};
|
||||
|
||||
const handleChange = (v: string | null) => {
|
||||
onChange?.(v ?? "");
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value || undefined}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
style={{ width: "100%", ...style }}
|
||||
allowClear={allowClear}
|
||||
disabled={disabled}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
searchValue={searchInput}
|
||||
onPopupScroll={handlePopupScroll}
|
||||
loading={isLoading}
|
||||
notFoundContent={isLoading ? <LoadingOutlined spin /> : "No key aliases found"}
|
||||
options={options}
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
{isFetchingNextPage && (
|
||||
<div style={{ textAlign: "center", padding: 8 }}>
|
||||
<LoadingOutlined spin />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -28,7 +28,6 @@ vi.mock("./networking", async (importOriginal) => {
|
||||
|
||||
// Mock filter helpers
|
||||
vi.mock("./key_team_helpers/filter_helpers", () => ({
|
||||
fetchAllKeyAliases: vi.fn().mockResolvedValue(["test-key-alias"]),
|
||||
fetchAllTeams: vi.fn().mockResolvedValue([
|
||||
{
|
||||
team_id: "team-1",
|
||||
@ -195,7 +194,6 @@ beforeEach(() => {
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [mockKey],
|
||||
allKeyAliases: ["test-key-alias"],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
@ -304,7 +302,6 @@ it("should show 'No keys found' message when filteredKeys is empty", () => {
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [],
|
||||
allKeyAliases: [],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
@ -342,7 +339,6 @@ it("should handle models with more than 3 entries to trigger expansion UI", () =
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [keyWithManyModels],
|
||||
allKeyAliases: ["test-key-alias"],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
@ -478,7 +474,6 @@ it("should display 'Default Proxy Admin' for user_id when value is 'default_user
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [keyWithDefaultUserId],
|
||||
allKeyAliases: ["test-key-alias"],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
@ -518,7 +513,6 @@ it("should display 'Default Proxy Admin' for created_by when value is 'default_u
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [keyWithDefaultCreatedBy],
|
||||
allKeyAliases: ["test-key-alias"],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
@ -561,7 +555,6 @@ it("should render table without crashing when models is null", async () => {
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [keyWithNullModels],
|
||||
allKeyAliases: ["test-key-alias"],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
@ -602,7 +595,6 @@ it("should render table without crashing when models is undefined", async () =>
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [keyWithUndefinedModels],
|
||||
allKeyAliases: ["test-key-alias"],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
@ -678,7 +670,6 @@ it("should display 'Unknown' for last_active when value is null", async () => {
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [keyWithNullLastActive],
|
||||
allKeyAliases: ["test-key-alias"],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
|
||||
@ -29,6 +29,7 @@ import { Popover, Skeleton, Tooltip } from "antd";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
|
||||
import { useFilterLogic } from "../key_team_helpers/filter_logic";
|
||||
import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect";
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
import FilterComponent, { FilterOption } from "../molecules/filter";
|
||||
import { Organization } from "../networking";
|
||||
@ -90,7 +91,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
|
||||
|
||||
// Use the filter logic hook
|
||||
|
||||
const { filters, filteredKeys, allKeyAliases, allTeams, allOrganizations, handleFilterChange, handleFilterReset } =
|
||||
const { filters, filteredKeys, allTeams, allOrganizations, handleFilterChange, handleFilterReset } =
|
||||
useFilterLogic({
|
||||
keys: keys?.keys || [],
|
||||
teams,
|
||||
@ -509,19 +510,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
|
||||
{
|
||||
name: "Key Alias",
|
||||
label: "Key Alias",
|
||||
isSearchable: true,
|
||||
searchFn: async (searchText) => {
|
||||
const filteredKeyAliases = allKeyAliases.filter((key) => {
|
||||
return key.toLowerCase().includes(searchText.toLowerCase());
|
||||
});
|
||||
|
||||
return filteredKeyAliases.map((key) => {
|
||||
return {
|
||||
label: key,
|
||||
value: key,
|
||||
};
|
||||
});
|
||||
},
|
||||
customComponent: PaginatedKeyAliasSelect,
|
||||
},
|
||||
{
|
||||
name: "User ID",
|
||||
|
||||
@ -1,26 +1,7 @@
|
||||
import { teamListCall, organizationListCall, keyAliasesCall } from "../networking"
|
||||
import { teamListCall, organizationListCall } from "../networking"
|
||||
import { Team } from "./key_list";
|
||||
import { Organization } from "../networking";
|
||||
|
||||
/**
|
||||
* Fetches all key aliases via the dedicated /key/aliases endpoint
|
||||
* @param accessToken The access token for API authentication
|
||||
* @returns Array of all unique key aliases
|
||||
*/
|
||||
export const fetchAllKeyAliases = async (accessToken: string | null): Promise<string[]> => {
|
||||
if (!accessToken) return [];
|
||||
|
||||
try {
|
||||
const { aliases } = await keyAliasesCall(accessToken as unknown as string);
|
||||
// Defensive dedupe & null-guard
|
||||
return Array.from(new Set((aliases || []).filter(Boolean)));
|
||||
} catch (error) {
|
||||
console.error("Error fetching all key aliases:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Fetches all teams across all pages
|
||||
* @param accessToken The access token for API authentication
|
||||
|
||||
@ -2,8 +2,7 @@ import { useCallback, useEffect, useState, useRef } from "react";
|
||||
import { KeyResponse } from "../key_team_helpers/key_list";
|
||||
import { keyListCall, Organization } from "../networking";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchAllKeyAliases, fetchAllOrganizations, fetchAllTeams } from "./filter_helpers";
|
||||
import { fetchAllOrganizations, fetchAllTeams } from "./filter_helpers";
|
||||
import { debounce } from "lodash";
|
||||
import { defaultPageSize } from "../constants";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
@ -122,16 +121,6 @@ export function useFilterLogic({
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
const queryAllKeysQuery = useQuery({
|
||||
queryKey: ["allKeys"],
|
||||
queryFn: async () => {
|
||||
if (!accessToken) throw new Error("Access token required");
|
||||
return await fetchAllKeyAliases(accessToken);
|
||||
},
|
||||
enabled: !!accessToken,
|
||||
});
|
||||
const allKeyAliases = queryAllKeysQuery.data || [];
|
||||
|
||||
// Update teams and organizations when props change
|
||||
useEffect(() => {
|
||||
if (teams && teams.length > 0) {
|
||||
@ -185,7 +174,6 @@ export function useFilterLogic({
|
||||
return {
|
||||
filters,
|
||||
filteredKeys,
|
||||
allKeyAliases,
|
||||
allTeams,
|
||||
allOrganizations,
|
||||
handleFilterChange,
|
||||
|
||||
@ -3329,13 +3329,33 @@ export const keyListCall = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const keyAliasesCall = async (accessToken: string): Promise<{ aliases: string[] }> => {
|
||||
export interface PaginatedKeyAliasResponse {
|
||||
aliases: string[];
|
||||
total_count: number;
|
||||
current_page: number;
|
||||
total_pages: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const keyAliasesCall = async (
|
||||
accessToken: string,
|
||||
page: number = 1,
|
||||
size: number = 50,
|
||||
search?: string,
|
||||
): Promise<PaginatedKeyAliasResponse> => {
|
||||
/**
|
||||
* Get all key aliases from proxy
|
||||
* Get key aliases from proxy with pagination and optional search
|
||||
*/
|
||||
try {
|
||||
const params = new URLSearchParams(
|
||||
Object.entries({
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
...(search ? { search } : {}),
|
||||
}),
|
||||
);
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/key/aliases` : `/key/aliases`;
|
||||
console.log("in keyAliasesCall");
|
||||
url = `${url}?${params}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
@ -3354,7 +3374,7 @@ export const keyAliasesCall = async (accessToken: string): Promise<{ aliases: st
|
||||
|
||||
const data = await response.json();
|
||||
console.log("/key/aliases API Response:", data);
|
||||
return data; // { aliases: string[] }
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch key aliases:", error);
|
||||
throw error;
|
||||
|
||||
@ -13,7 +13,6 @@ vi.mock("./log_filter_logic", () => ({
|
||||
filters: {},
|
||||
filteredLogs: { data: [], total: 0, page: 1, page_size: 50, total_pages: 1 },
|
||||
allTeams: [],
|
||||
allKeyAliases: [],
|
||||
handleFilterChange: vi.fn(),
|
||||
handleFilterReset: mockHandleFilterResetFromHook,
|
||||
})),
|
||||
@ -37,7 +36,6 @@ vi.mock("../networking", async (importOriginal) => {
|
||||
});
|
||||
|
||||
vi.mock("../key_team_helpers/filter_helpers", () => ({
|
||||
fetchAllKeyAliases: vi.fn().mockResolvedValue([]),
|
||||
fetchAllTeams: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
|
||||
@ -11,8 +11,8 @@ import { Button, Tag, Tooltip } from "antd";
|
||||
import { internalUserRoles } from "../../utils/roles";
|
||||
import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage";
|
||||
import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage";
|
||||
import { fetchAllKeyAliases } from "../key_team_helpers/filter_helpers";
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect";
|
||||
import { PaginatedModelSelect } from "../ModelSelect/PaginatedModelSelect/PaginatedModelSelect";
|
||||
import FilterComponent, { FilterOption } from "../molecules/filter";
|
||||
import { allEndUsersCall, keyInfoV1Call, uiSpendLogsCall } from "../networking";
|
||||
@ -242,7 +242,6 @@ export default function SpendLogsTable({
|
||||
filteredLogs,
|
||||
hasBackendFilters,
|
||||
allTeams: hookAllTeams,
|
||||
allKeyAliases,
|
||||
handleFilterChange,
|
||||
handleFilterReset: handleFilterResetFromHook,
|
||||
} = useLogFilterLogic({
|
||||
@ -424,16 +423,7 @@ export default function SpendLogsTable({
|
||||
{
|
||||
name: "Key Alias",
|
||||
label: "Key Alias",
|
||||
isSearchable: true,
|
||||
searchFn: async (searchText: string) => {
|
||||
if (!accessToken) return [];
|
||||
const keyAliases = await fetchAllKeyAliases(accessToken);
|
||||
const filtered = keyAliases.filter((alias) => alias.toLowerCase().includes(searchText.toLowerCase()));
|
||||
return filtered.map((alias) => ({
|
||||
label: alias,
|
||||
value: alias,
|
||||
}));
|
||||
},
|
||||
customComponent: PaginatedKeyAliasSelect,
|
||||
},
|
||||
{
|
||||
name: "End User",
|
||||
|
||||
@ -11,7 +11,6 @@ vi.mock("../networking", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/key_team_helpers/filter_helpers", () => ({
|
||||
fetchAllKeyAliases: vi.fn().mockResolvedValue([]),
|
||||
fetchAllTeams: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
@ -82,7 +81,7 @@ describe("useLogFilterLogic", () => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
it("should return filters, filteredLogs, allKeyAliases, allTeams, handleFilterChange, and handleFilterReset", () => {
|
||||
it("should return filters, filteredLogs, allTeams, handleFilterChange, and handleFilterReset", () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useLogFilterLogic({
|
||||
@ -94,7 +93,6 @@ describe("useLogFilterLogic", () => {
|
||||
|
||||
expect(result.current.filters).toBeDefined();
|
||||
expect(result.current.filteredLogs).toBeDefined();
|
||||
expect(result.current.allKeyAliases).toBeDefined();
|
||||
expect(result.current).toHaveProperty("allTeams");
|
||||
expect(result.current.handleFilterChange).toBeDefined();
|
||||
expect(result.current.handleFilterReset).toBeDefined();
|
||||
|
||||
@ -3,7 +3,7 @@ import { useCallback, useEffect, useState, useRef, useMemo } from "react";
|
||||
import { uiSpendLogsCall } from "../networking";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchAllKeyAliases, fetchAllTeams } from "../../components/key_team_helpers/filter_helpers";
|
||||
import { fetchAllTeams } from "../../components/key_team_helpers/filter_helpers";
|
||||
import { debounce } from "lodash";
|
||||
import { defaultPageSize } from "../constants";
|
||||
import { PaginatedResponse } from ".";
|
||||
@ -132,16 +132,6 @@ export function useLogFilterLogic({
|
||||
return () => debouncedSearch.cancel();
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const queryAllKeysQuery = useQuery({
|
||||
queryKey: ["allKeys"],
|
||||
queryFn: async () => {
|
||||
if (!accessToken) throw new Error("Access token required");
|
||||
return await fetchAllKeyAliases(accessToken);
|
||||
},
|
||||
enabled: !!accessToken,
|
||||
});
|
||||
const allKeyAliases = queryAllKeysQuery.data || [];
|
||||
|
||||
// Determine when backend filters are active (server-side filtering)
|
||||
const hasBackendFilters = useMemo(
|
||||
() =>
|
||||
@ -310,7 +300,6 @@ export function useLogFilterLogic({
|
||||
filters,
|
||||
filteredLogs,
|
||||
hasBackendFilters,
|
||||
allKeyAliases,
|
||||
allTeams,
|
||||
handleFilterChange,
|
||||
handleFilterReset,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user