mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
fix(ui): virtual keys filter silently resets after navigating away and back
The Virtual Keys page kept filter UI state in useFilterLogic but drove the table through a parallel one-off keyListCall plus a [keys, filters] effect that post-filtered props.keys client-side. Whenever the main useKeys query refetched (tab re-focus, storage event, staleTime expiry, remount), the effect re-ran and overwrote the server-filtered rows with props.keys — which it only knew how to filter by Team ID / Organization ID. Key Alias and User ID had no client-side branch, so those filters silently disappeared while their UI chips stayed selected. Let useKeys own the filter parameters so React Query's cache key tracks them: same query → same cache entry, remounts and refetches now always fetch the filtered set. Debounce the filter values (300ms) before feeding them into useKeys so typing in a text filter doesn't fire per keystroke, and reset pagination to page 1 when filters change. - filter_logic.tsx: drop debouncedSearch, the [keys, filters] effect, and filteredKeys/filteredTotalCount — the hook is now a pure state holder. - VirtualKeysTable.tsx: pipe debounced filters into useKeys options; read rows and total_count directly from the query; remove the redundant skipDebounce hop on the sort handler. - filter_logic.test.tsx: rewrite for the new contract and add a regression test that filter state survives re-renders. - VirtualKeysTable.test.tsx: update mocks to drive rows through useKeys; add an assertion that filter values are forwarded into useKeys. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d3891e6eae
commit
f301297125
4 changed files with 216 additions and 274 deletions
|
|
@ -206,8 +206,6 @@ beforeEach(() => {
|
|||
"Sort By": "created_at",
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [mockKey],
|
||||
filteredTotalCount: null,
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
|
|
@ -306,6 +304,18 @@ it("should show loading message only on initial load (isPending)", () => {
|
|||
|
||||
it("should show 'No keys found' message when filteredKeys is empty", () => {
|
||||
// Mock empty filteredKeys
|
||||
mockUseKeys.mockReturnValue({
|
||||
data: {
|
||||
keys: [],
|
||||
total_count: 0,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
} as KeysResponse,
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
mockUseFilterLogic.mockReturnValue({
|
||||
filters: {
|
||||
"Team ID": "",
|
||||
|
|
@ -315,7 +325,6 @@ it("should show 'No keys found' message when filteredKeys is empty", () => {
|
|||
"Sort By": "created_at",
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
|
|
@ -343,6 +352,18 @@ it("should handle models with more than 3 entries to trigger expansion UI", () =
|
|||
models: ["gpt-3.5-turbo", "gpt-4", "gpt-4-turbo", "claude-3", "claude-3-5-sonnet"],
|
||||
};
|
||||
|
||||
mockUseKeys.mockReturnValue({
|
||||
data: {
|
||||
keys: [keyWithManyModels],
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
} as KeysResponse,
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
mockUseFilterLogic.mockReturnValue({
|
||||
filters: {
|
||||
"Team ID": "",
|
||||
|
|
@ -352,7 +373,6 @@ it("should handle models with more than 3 entries to trigger expansion UI", () =
|
|||
"Sort By": "created_at",
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [keyWithManyModels],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
|
|
@ -480,6 +500,18 @@ it("should display 'Default Proxy Admin' for user_id when value is 'default_user
|
|||
user: { user_id: "default_user_id", user_email: "", user_alias: null },
|
||||
};
|
||||
|
||||
mockUseKeys.mockReturnValue({
|
||||
data: {
|
||||
keys: [keyWithDefaultUserId],
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
} as KeysResponse,
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
mockUseFilterLogic.mockReturnValue({
|
||||
filters: {
|
||||
"Team ID": "",
|
||||
|
|
@ -489,7 +521,6 @@ it("should display 'Default Proxy Admin' for user_id when value is 'default_user
|
|||
"Sort By": "created_at",
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [keyWithDefaultUserId],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
|
|
@ -519,6 +550,18 @@ it("should display 'Default Proxy Admin' for created_by when value is 'default_u
|
|||
created_by: "default_user_id",
|
||||
};
|
||||
|
||||
mockUseKeys.mockReturnValue({
|
||||
data: {
|
||||
keys: [keyWithDefaultCreatedBy],
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
} as KeysResponse,
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
mockUseFilterLogic.mockReturnValue({
|
||||
filters: {
|
||||
"Team ID": "",
|
||||
|
|
@ -528,7 +571,6 @@ it("should display 'Default Proxy Admin' for created_by when value is 'default_u
|
|||
"Sort By": "created_at",
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [keyWithDefaultCreatedBy],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
|
|
@ -566,6 +608,18 @@ it("should display created_by_user email in 'Created By' column when available",
|
|||
},
|
||||
};
|
||||
|
||||
mockUseKeys.mockReturnValue({
|
||||
data: {
|
||||
keys: [keyWithCreatedByUser],
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
} as KeysResponse,
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
mockUseFilterLogic.mockReturnValue({
|
||||
filters: {
|
||||
"Team ID": "",
|
||||
|
|
@ -575,7 +629,6 @@ it("should display created_by_user email in 'Created By' column when available",
|
|||
"Sort By": "created_at",
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [keyWithCreatedByUser],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
|
|
@ -610,6 +663,18 @@ it("should display created_by_user alias over email when both available", async
|
|||
},
|
||||
};
|
||||
|
||||
mockUseKeys.mockReturnValue({
|
||||
data: {
|
||||
keys: [keyWithCreatedByUser],
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
} as KeysResponse,
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
mockUseFilterLogic.mockReturnValue({
|
||||
filters: {
|
||||
"Team ID": "",
|
||||
|
|
@ -619,7 +684,6 @@ it("should display created_by_user alias over email when both available", async
|
|||
"Sort By": "created_at",
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [keyWithCreatedByUser],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
|
|
@ -649,6 +713,18 @@ it("should render table without crashing when models is null", async () => {
|
|||
models: null as unknown as string[],
|
||||
};
|
||||
|
||||
mockUseKeys.mockReturnValue({
|
||||
data: {
|
||||
keys: [keyWithNullModels],
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
} as KeysResponse,
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
mockUseFilterLogic.mockReturnValue({
|
||||
filters: {
|
||||
"Team ID": "",
|
||||
|
|
@ -658,7 +734,6 @@ it("should render table without crashing when models is null", async () => {
|
|||
"Sort By": "created_at",
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [keyWithNullModels],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
|
|
@ -689,6 +764,18 @@ it("should render table without crashing when models is undefined", async () =>
|
|||
models: undefined as unknown as string[],
|
||||
};
|
||||
|
||||
mockUseKeys.mockReturnValue({
|
||||
data: {
|
||||
keys: [keyWithUndefinedModels],
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
} as KeysResponse,
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
mockUseFilterLogic.mockReturnValue({
|
||||
filters: {
|
||||
"Team ID": "",
|
||||
|
|
@ -698,7 +785,6 @@ it("should render table without crashing when models is undefined", async () =>
|
|||
"Sort By": "created_at",
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [keyWithUndefinedModels],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
|
|
@ -764,6 +850,18 @@ it("should display 'Unknown' for last_active when value is null", async () => {
|
|||
last_active: null,
|
||||
};
|
||||
|
||||
mockUseKeys.mockReturnValue({
|
||||
data: {
|
||||
keys: [keyWithNullLastActive],
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
} as KeysResponse,
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
mockUseFilterLogic.mockReturnValue({
|
||||
filters: {
|
||||
"Team ID": "",
|
||||
|
|
@ -773,7 +871,6 @@ it("should display 'Unknown' for last_active when value is null", async () => {
|
|||
"Sort By": "created_at",
|
||||
"Sort Order": "desc",
|
||||
},
|
||||
filteredKeys: [keyWithNullLastActive],
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
|
|
@ -820,8 +917,6 @@ describe("pagination display – total count and page count", () => {
|
|||
|
||||
mockUseFilterLogic.mockReturnValue({
|
||||
filters: { "Team ID": "", "Organization ID": "", "Key Alias": "", "User ID": "", "Sort By": "created_at", "Sort Order": "desc" },
|
||||
filteredKeys: [mockKey],
|
||||
filteredTotalCount: null,
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
|
|
@ -836,13 +931,15 @@ describe("pagination display – total count and page count", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should show filteredTotalCount in pagination text when a filter search returns results", async () => {
|
||||
it("should show the server-returned filtered total when a filter is active", async () => {
|
||||
// With the filter wired into useKeys, the server returns the filtered
|
||||
// total_count directly — the component has no separate client-side total.
|
||||
mockUseKeys.mockReturnValue({
|
||||
data: {
|
||||
keys: [mockKey],
|
||||
total_count: 509,
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
total_pages: 11,
|
||||
total_pages: 1,
|
||||
} as KeysResponse,
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
|
|
@ -851,8 +948,6 @@ describe("pagination display – total count and page count", () => {
|
|||
|
||||
mockUseFilterLogic.mockReturnValue({
|
||||
filters: { "Team ID": "", "Organization ID": "", "Key Alias": "aaaaa", "User ID": "", "Sort By": "created_at", "Sort Order": "desc" },
|
||||
filteredKeys: [mockKey],
|
||||
filteredTotalCount: 1,
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
|
|
@ -867,23 +962,9 @@ describe("pagination display – total count and page count", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should not show stale unfiltered totals when filteredTotalCount is set", async () => {
|
||||
mockUseKeys.mockReturnValue({
|
||||
data: {
|
||||
keys: [mockKey],
|
||||
total_count: 509,
|
||||
current_page: 1,
|
||||
total_pages: 11,
|
||||
} as KeysResponse,
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
it("should pass current filter values into useKeys so remount/refetch keeps the filter applied", async () => {
|
||||
mockUseFilterLogic.mockReturnValue({
|
||||
filters: { "Team ID": "", "Organization ID": "", "Key Alias": "aaaaa", "User ID": "", "Sort By": "created_at", "Sort Order": "desc" },
|
||||
filteredKeys: [mockKey],
|
||||
filteredTotalCount: 1,
|
||||
allTeams: [mockTeam],
|
||||
allOrganizations: [mockOrganization],
|
||||
handleFilterChange: vi.fn(),
|
||||
|
|
@ -892,9 +973,14 @@ describe("pagination display – total count and page count", () => {
|
|||
|
||||
renderWithProviders(<VirtualKeysTable {...defaultMockProps} />);
|
||||
|
||||
// useKeys must be invoked with the selected alias so that a later refetch
|
||||
// (e.g. after navigating away and back) still fetches the filtered set.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/509 results/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/of 11/)).not.toBeInTheDocument();
|
||||
expect(mockUseKeys).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
expect.objectContaining({ selectedKeyAlias: "aaaaa" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -81,6 +81,32 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
|
|||
const sortBy = sorting.length > 0 ? sorting[0].id : null;
|
||||
const sortOrder = sorting.length > 0 ? (sorting[0].desc ? "desc" : "asc") : null;
|
||||
|
||||
// Use the filter logic hook
|
||||
const { filters, allTeams, allOrganizations, handleFilterChange, handleFilterReset } =
|
||||
useFilterLogic({
|
||||
teams,
|
||||
organizations,
|
||||
});
|
||||
|
||||
// Debounce filter values before feeding them into useKeys so that each
|
||||
// keystroke in a text filter (e.g. Key Alias) doesn't fire a request.
|
||||
const [debouncedFilters, setDebouncedFilters] = useState(filters);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedFilters(filters), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [filters]);
|
||||
|
||||
// When active filters change, jump back to the first page — otherwise we
|
||||
// may land on an out-of-range page of the filtered result set.
|
||||
useEffect(() => {
|
||||
setTablePagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }));
|
||||
}, [
|
||||
debouncedFilters["Team ID"],
|
||||
debouncedFilters["Organization ID"],
|
||||
debouncedFilters["Key Alias"],
|
||||
debouncedFilters["User ID"],
|
||||
]);
|
||||
|
||||
const {
|
||||
data: keys,
|
||||
isPending: isLoading,
|
||||
|
|
@ -91,18 +117,13 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
|
|||
sortBy: sortBy || undefined,
|
||||
sortOrder: sortOrder || undefined,
|
||||
expand: "user",
|
||||
teamID: debouncedFilters["Team ID"] || undefined,
|
||||
organizationID: debouncedFilters["Organization ID"] || undefined,
|
||||
selectedKeyAlias: debouncedFilters["Key Alias"] || undefined,
|
||||
userID: debouncedFilters["User ID"] || undefined,
|
||||
});
|
||||
const [expandedAccordions, setExpandedAccordions] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Use the filter logic hook
|
||||
|
||||
const { filters, filteredKeys, filteredTotalCount, allTeams, allOrganizations, handleFilterChange, handleFilterReset } =
|
||||
useFilterLogic({
|
||||
keys: keys?.keys || [],
|
||||
teams,
|
||||
organizations,
|
||||
});
|
||||
|
||||
// Defer the transition so the button stays in loading state until the table
|
||||
// has rendered with the new data (mirrors the spend-logs pattern)
|
||||
const isFetchingDeferred = useDeferredValue(isFetching);
|
||||
|
|
@ -112,7 +133,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
|
|||
refetch();
|
||||
};
|
||||
|
||||
const totalCount = filteredTotalCount ?? keys?.total_count ?? 0;
|
||||
const totalCount = keys?.total_count ?? 0;
|
||||
|
||||
// Add a useEffect to call refresh when a key is created
|
||||
useEffect(() => {
|
||||
|
|
@ -618,7 +639,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
|
|||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredKeys,
|
||||
data: keys?.keys ?? [],
|
||||
columns: columns.filter((col) => col.id !== "expander"),
|
||||
columnResizeMode: "onChange",
|
||||
columnResizeDirection: "ltr",
|
||||
|
|
@ -633,16 +654,6 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
|
|||
const sortState = newSorting[0];
|
||||
const sortBy = sortState.id;
|
||||
const sortOrder = sortState.desc ? "desc" : "asc";
|
||||
// Update filters state without triggering debouncedSearch
|
||||
// The useKeys hook will automatically refetch with the new sort parameters
|
||||
handleFilterChange(
|
||||
{
|
||||
...filters,
|
||||
"Sort By": sortBy,
|
||||
"Sort Order": sortOrder,
|
||||
},
|
||||
true, // skipDebounce - let useKeys handle the API call with correct page size
|
||||
);
|
||||
onSortChange?.(sortBy, sortOrder);
|
||||
}
|
||||
},
|
||||
|
|
@ -833,7 +844,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
|
|||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredKeys.length > 0 ? (
|
||||
) : (keys?.keys?.length ?? 0) > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="h-8">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
|
|
|
|||
|
|
@ -1,149 +1,90 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useFilterLogic } from "./filter_logic";
|
||||
import { keyListCall } from "../networking";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
keyListCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./filter_helpers", () => ({
|
||||
fetchAllTeams: vi.fn().mockResolvedValue([]),
|
||||
fetchAllOrganizations: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const mockKey = {
|
||||
token: "abc123",
|
||||
key_alias: "aaaaa",
|
||||
team_id: null,
|
||||
organization_id: null,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
keys: [mockKey] as any[],
|
||||
teams: [],
|
||||
organizations: [],
|
||||
};
|
||||
|
||||
const makeApiResponse = (overrides: { keys?: any[]; total_count?: number; total_pages?: number } = {}) => ({
|
||||
keys: overrides.keys ?? [mockKey],
|
||||
total_count: overrides.total_count ?? 1,
|
||||
current_page: 1,
|
||||
total_pages: overrides.total_pages ?? 1,
|
||||
});
|
||||
const DEFAULT_FILTERS = {
|
||||
"Team ID": "",
|
||||
"Organization ID": "",
|
||||
"Key Alias": "",
|
||||
"User ID": "",
|
||||
"Sort By": "created_at",
|
||||
"Sort Order": "desc",
|
||||
};
|
||||
|
||||
describe("useFilterLogic – filteredTotalCount", () => {
|
||||
describe("useFilterLogic – filter state management", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(keyListCall).mockResolvedValue(makeApiResponse({ total_count: 509, total_pages: 11 }));
|
||||
});
|
||||
|
||||
it("should expose filteredTotalCount as null before any filter search runs", () => {
|
||||
it("starts with default (empty) filter values", () => {
|
||||
const { result } = renderHook(() => useFilterLogic(defaultProps));
|
||||
|
||||
expect(result.current.filteredTotalCount).toBeNull();
|
||||
expect(result.current.filters).toEqual(DEFAULT_FILTERS);
|
||||
});
|
||||
|
||||
it("should set filteredTotalCount to the API total_count after a Key Alias filter is applied", async () => {
|
||||
vi.mocked(keyListCall).mockResolvedValue(makeApiResponse({ keys: [mockKey], total_count: 1, total_pages: 1 }));
|
||||
|
||||
const { result } = renderHook(() => useFilterLogic(defaultProps));
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Alias": "aaaaa" });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.filteredTotalCount).toBe(1);
|
||||
}, { timeout: 500 });
|
||||
});
|
||||
|
||||
it("should reflect the filtered total_count even when it differs from the full key count", async () => {
|
||||
vi.mocked(keyListCall).mockResolvedValue(makeApiResponse({ total_count: 7, total_pages: 1 }));
|
||||
|
||||
const { result } = renderHook(() => useFilterLogic(defaultProps));
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Team ID": "team-x" });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.filteredTotalCount).toBe(7);
|
||||
}, { timeout: 500 });
|
||||
});
|
||||
|
||||
it("should reset filteredTotalCount to null when handleFilterReset is called", async () => {
|
||||
vi.mocked(keyListCall).mockResolvedValue(makeApiResponse({ total_count: 1 }));
|
||||
|
||||
const { result } = renderHook(() => useFilterLogic(defaultProps));
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Alias": "aaaaa" });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.filteredTotalCount).toBe(1);
|
||||
}, { timeout: 500 });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterReset();
|
||||
});
|
||||
|
||||
// filteredTotalCount resets synchronously before the debounced reset search completes
|
||||
expect(result.current.filteredTotalCount).toBeNull();
|
||||
});
|
||||
|
||||
it("should pass the Key Alias value to keyListCall", async () => {
|
||||
vi.mocked(keyListCall).mockResolvedValue(makeApiResponse({ total_count: 2 }));
|
||||
|
||||
it("updates the Key Alias filter when handleFilterChange is called", () => {
|
||||
const { result } = renderHook(() => useFilterLogic(defaultProps));
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Alias": "my-alias" });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(keyListCall).toHaveBeenCalledWith(
|
||||
expect.any(String), // accessToken
|
||||
null, // organizationID (empty → null)
|
||||
null, // teamID (empty → null)
|
||||
"my-alias", // selectedKeyAlias ← the filter value
|
||||
null, // userID
|
||||
null, // keyHash
|
||||
1, // page (resets to 1 on filter change)
|
||||
expect.any(Number),// pageSize (defaultPageSize)
|
||||
expect.anything(), // sortBy
|
||||
expect.anything(), // sortOrder
|
||||
);
|
||||
}, { timeout: 500 });
|
||||
expect(result.current.filters["Key Alias"]).toBe("my-alias");
|
||||
});
|
||||
|
||||
it("should not update filteredTotalCount when keyListCall throws", async () => {
|
||||
vi.mocked(keyListCall).mockRejectedValue(new Error("Network error"));
|
||||
it("preserves filter state across re-renders (regression: bug where results reset on remount)", () => {
|
||||
const { result, rerender } = renderHook(() => useFilterLogic(defaultProps));
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Alias": "keep-me" });
|
||||
});
|
||||
expect(result.current.filters["Key Alias"]).toBe("keep-me");
|
||||
|
||||
// Re-render with the same props; filter must remain applied so downstream
|
||||
// useKeys is still invoked with the alias instead of falling back to "all".
|
||||
rerender();
|
||||
expect(result.current.filters["Key Alias"]).toBe("keep-me");
|
||||
});
|
||||
|
||||
it("resets filters to defaults when handleFilterReset is called", () => {
|
||||
const { result } = renderHook(() => useFilterLogic(defaultProps));
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Alias": "bad-alias" });
|
||||
result.current.handleFilterChange({ "Team ID": "team-x", "Key Alias": "abc" });
|
||||
});
|
||||
expect(result.current.filters["Team ID"]).toBe("team-x");
|
||||
expect(result.current.filters["Key Alias"]).toBe("abc");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(keyListCall).toHaveBeenCalled();
|
||||
}, { timeout: 500 });
|
||||
|
||||
expect(result.current.filteredTotalCount).toBeNull();
|
||||
act(() => {
|
||||
result.current.handleFilterReset();
|
||||
});
|
||||
expect(result.current.filters).toEqual(DEFAULT_FILTERS);
|
||||
});
|
||||
|
||||
it("should not trigger a debounced search when skipDebounce is true", async () => {
|
||||
it("handleFilterChange replaces the entire filter set (missing keys become empty)", () => {
|
||||
const { result } = renderHook(() => useFilterLogic(defaultProps));
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Sort By": "spend", "Sort Order": "asc" }, true);
|
||||
result.current.handleFilterChange({ "Team ID": "team-1", "Key Alias": "alpha" });
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "User ID": "user-1" });
|
||||
});
|
||||
|
||||
expect(keyListCall).not.toHaveBeenCalled();
|
||||
expect(result.current.filteredTotalCount).toBeNull();
|
||||
expect(result.current.filters).toEqual({
|
||||
...DEFAULT_FILTERS,
|
||||
"User ID": "user-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { useCallback, useEffect, useState, useRef } from "react";
|
||||
import { KeyResponse } from "../key_team_helpers/key_list";
|
||||
import { keyListCall, Organization } from "../networking";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
import { Organization } from "../networking";
|
||||
import { fetchAllOrganizations, fetchAllTeams } from "./filter_helpers";
|
||||
import { debounce } from "lodash";
|
||||
import { defaultPageSize } from "../constants";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
export interface FilterState {
|
||||
|
|
@ -17,133 +14,58 @@ export interface FilterState {
|
|||
"Sort Order": string;
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: FilterState = {
|
||||
"Team ID": "",
|
||||
"Organization ID": "",
|
||||
"Key Alias": "",
|
||||
"User ID": "",
|
||||
"Sort By": "created_at",
|
||||
"Sort Order": "desc",
|
||||
};
|
||||
|
||||
export function useFilterLogic({
|
||||
keys,
|
||||
teams,
|
||||
organizations,
|
||||
}: {
|
||||
keys: KeyResponse[];
|
||||
teams: Team[] | null;
|
||||
organizations: Organization[] | null;
|
||||
}) {
|
||||
const defaultFilters: FilterState = {
|
||||
"Team ID": "",
|
||||
"Organization ID": "",
|
||||
"Key Alias": "",
|
||||
"User ID": "",
|
||||
"Sort By": "created_at",
|
||||
"Sort Order": "desc",
|
||||
};
|
||||
const { accessToken } = useAuthorized();
|
||||
const [filters, setFilters] = useState<FilterState>(defaultFilters);
|
||||
const [filters, setFilters] = useState<FilterState>(DEFAULT_FILTERS);
|
||||
const [allTeams, setAllTeams] = useState<Team[]>(teams || []);
|
||||
const [allOrganizations, setAllOrganizations] = useState<Organization[]>(organizations || []);
|
||||
const [filteredKeys, setFilteredKeys] = useState<KeyResponse[]>(keys);
|
||||
const [filteredTotalCount, setFilteredTotalCount] = useState<number | null>(null);
|
||||
const lastSearchTimestamp = useRef(0);
|
||||
const debouncedSearch = useCallback(
|
||||
debounce(async (filters: FilterState) => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTimestamp = Date.now();
|
||||
lastSearchTimestamp.current = currentTimestamp;
|
||||
|
||||
try {
|
||||
// Make the API call using userListCall with all filter parameters
|
||||
const data = await keyListCall(
|
||||
accessToken,
|
||||
filters["Organization ID"] || null,
|
||||
filters["Team ID"] || null,
|
||||
filters["Key Alias"] || null,
|
||||
filters["User ID"] || null,
|
||||
filters["Key Hash"] || null,
|
||||
1, // Reset to first page when searching
|
||||
defaultPageSize,
|
||||
filters["Sort By"] || null,
|
||||
filters["Sort Order"] || null,
|
||||
);
|
||||
|
||||
// Only update state if this is the most recent search
|
||||
if (currentTimestamp === lastSearchTimestamp.current) {
|
||||
if (data) {
|
||||
setFilteredKeys(data.keys);
|
||||
setFilteredTotalCount(data.total_count ?? null);
|
||||
console.log("called from debouncedSearch filters:", JSON.stringify(filters));
|
||||
console.log("called from debouncedSearch data:", JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error searching users:", error);
|
||||
}
|
||||
}, 300),
|
||||
[accessToken],
|
||||
);
|
||||
// Apply filters to keys whenever keys or filters change
|
||||
useEffect(() => {
|
||||
if (!keys) {
|
||||
setFilteredKeys([]);
|
||||
return;
|
||||
}
|
||||
if (!accessToken) return;
|
||||
|
||||
let result = [...keys];
|
||||
|
||||
// Apply Team ID filter
|
||||
if (filters["Team ID"]) {
|
||||
result = result.filter((key) => key.team_id === filters["Team ID"]);
|
||||
}
|
||||
|
||||
// Apply Organization ID filter
|
||||
if (filters["Organization ID"]) {
|
||||
result = result.filter((key) => (key.organization_id ?? key.org_id) === filters["Organization ID"]);
|
||||
}
|
||||
|
||||
setFilteredKeys(result);
|
||||
}, [keys, filters]);
|
||||
|
||||
// Fetch all data for filters when component mounts
|
||||
useEffect(() => {
|
||||
const loadAllFilterData = async () => {
|
||||
// Load all teams - no organization filter needed here
|
||||
const teamsData = await fetchAllTeams(accessToken);
|
||||
if (teamsData.length > 0) {
|
||||
setAllTeams(teamsData);
|
||||
}
|
||||
|
||||
// Load all organizations
|
||||
const orgsData = await fetchAllOrganizations(accessToken);
|
||||
if (orgsData.length > 0) {
|
||||
setAllOrganizations(orgsData);
|
||||
}
|
||||
};
|
||||
|
||||
if (accessToken) {
|
||||
loadAllFilterData();
|
||||
}
|
||||
loadAllFilterData();
|
||||
}, [accessToken]);
|
||||
|
||||
// Update teams and organizations when props change
|
||||
useEffect(() => {
|
||||
if (teams && teams.length > 0) {
|
||||
setAllTeams((prevTeams) => {
|
||||
// Only update if we don't already have a larger set of teams
|
||||
return prevTeams.length < teams.length ? teams : prevTeams;
|
||||
});
|
||||
setAllTeams((prevTeams) => (prevTeams.length < teams.length ? teams : prevTeams));
|
||||
}
|
||||
}, [teams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (organizations && organizations.length > 0) {
|
||||
setAllOrganizations((prevOrgs) => {
|
||||
// Only update if we don't already have a larger set of organizations
|
||||
return prevOrgs.length < organizations.length ? organizations : prevOrgs;
|
||||
});
|
||||
setAllOrganizations((prevOrgs) => (prevOrgs.length < organizations.length ? organizations : prevOrgs));
|
||||
}
|
||||
}, [organizations]);
|
||||
|
||||
const handleFilterChange = (newFilters: Record<string, string>, skipDebounce: boolean = false) => {
|
||||
// Update filters state
|
||||
const handleFilterChange = (newFilters: Record<string, string>) => {
|
||||
setFilters({
|
||||
"Team ID": newFilters["Team ID"] || "",
|
||||
"Organization ID": newFilters["Organization ID"] || "",
|
||||
|
|
@ -152,32 +74,14 @@ export function useFilterLogic({
|
|||
"Sort By": newFilters["Sort By"] || "created_at",
|
||||
"Sort Order": newFilters["Sort Order"] || "desc",
|
||||
});
|
||||
|
||||
// Only trigger debouncedSearch if skipDebounce is false
|
||||
// This allows sorting to be handled by the parent component's useKeys hook
|
||||
if (!skipDebounce) {
|
||||
// Fetch keys based on new filters
|
||||
const updatedFilters = {
|
||||
...filters,
|
||||
...newFilters,
|
||||
};
|
||||
debouncedSearch(updatedFilters);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterReset = () => {
|
||||
// Reset filters state
|
||||
setFilters(defaultFilters);
|
||||
setFilteredTotalCount(null);
|
||||
|
||||
// Reset selections
|
||||
debouncedSearch(defaultFilters);
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
};
|
||||
|
||||
return {
|
||||
filters,
|
||||
filteredKeys,
|
||||
filteredTotalCount,
|
||||
allTeams,
|
||||
allOrganizations,
|
||||
handleFilterChange,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue