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:
Bytechoreographer 2026-04-30 17:45:50 +08:00
parent d3891e6eae
commit f301297125
4 changed files with 216 additions and 274 deletions

View file

@ -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" }),
);
});
});
});

View file

@ -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) => (

View file

@ -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",
});
});
});

View file

@ -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,