[Feature] UI - Virtual Keys: Add refetch button and keep stale data during refetch

Show a Fetch/Fetching button next to "Showing X of Y results" that acts as
both a manual refetch trigger and a loading indicator. The "Loading keys..."
message now only appears on initial load; subsequent refetches keep the table
visible with stale data (via React Query's keepPreviousData).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-03-09 23:30:55 -07:00
parent 6fe82d3886
commit 9ee489863d
2 changed files with 100 additions and 19 deletions

View file

@ -262,8 +262,8 @@ it("should display user email correctly", async () => {
});
});
it("should show skeleton loaders when isLoading is true", () => {
// Mock loading state
it("should show loading message only on initial load (isPending)", () => {
// Mock initial loading state
mockUseKeys.mockReturnValue({
data: null,
isPending: true,
@ -283,7 +283,7 @@ it("should show skeleton loaders when isLoading is true", () => {
renderWithProviders(<VirtualKeysTable {...mockProps} />);
// Check that loading message is shown
// Check that loading message is shown on initial load
expect(screen.getByText("🚅 Loading keys...")).toBeInTheDocument();
// Check that actual key data is not shown
@ -795,3 +795,63 @@ describe("pagination display total count and page count", () => {
});
});
});
describe("refetch button", () => {
it("should show Fetch button in normal state", () => {
renderWithProviders(<VirtualKeysTable {...defaultMockProps} />);
const fetchButton = screen.getByTitle("Fetch data");
expect(fetchButton).toBeInTheDocument();
expect(fetchButton).not.toBeDisabled();
expect(screen.getByText("Fetch")).toBeInTheDocument();
});
it("should show Fetching state and keep table data visible during refetch", () => {
mockUseKeys.mockReturnValue({
data: {
keys: [mockKey],
total_count: 1,
current_page: 1,
total_pages: 1,
} as KeysResponse,
isPending: false,
isFetching: true,
refetch: vi.fn(),
} as any);
renderWithProviders(<VirtualKeysTable {...defaultMockProps} />);
// Button should show "Fetching" and be disabled
expect(screen.getByText("Fetching")).toBeInTheDocument();
const fetchButton = screen.getByTitle("Fetch data");
expect(fetchButton).toBeDisabled();
// Table data should still be visible (stale data)
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
// "Loading keys..." should NOT appear during refetch
expect(screen.queryByText("🚅 Loading keys...")).not.toBeInTheDocument();
});
it("should call refetch when Fetch button is clicked", () => {
const mockRefetch = vi.fn();
mockUseKeys.mockReturnValue({
data: {
keys: [mockKey],
total_count: 1,
current_page: 1,
total_pages: 1,
} as KeysResponse,
isPending: false,
isFetching: false,
refetch: mockRefetch,
} as any);
renderWithProviders(<VirtualKeysTable {...defaultMockProps} />);
const fetchButton = screen.getByTitle("Fetch data");
fireEvent.click(fetchButton);
expect(mockRefetch).toHaveBeenCalledTimes(1);
});
});

View file

@ -24,9 +24,9 @@ import {
TableRow,
Text,
} from "@tremor/react";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Popover, Skeleton, Tooltip } from "antd";
import React, { useEffect, useMemo, useState } from "react";
import { InfoCircleOutlined, SyncOutlined } from "@ant-design/icons";
import { Button as AntButton, Popover, Skeleton, Tooltip } from "antd";
import React, { useEffect, useDeferredValue, 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";
@ -97,6 +97,15 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
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);
const isButtonLoading = isFetching || isFetchingDeferred;
const handleRefresh = () => {
refetch();
};
const totalCount = filteredTotalCount ?? keys?.total_count ?? 0;
// Add a useEffect to call refresh when a key is created
@ -606,16 +615,28 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
</div>
<div className="flex items-center justify-between w-full mb-4">
{isLoading || isFetching ? (
<Skeleton.Node active style={{ width: 200, height: 20 }} />
) : (
<span className="inline-flex text-sm text-gray-700">
Showing {rangeLabel} of {totalCount} results
</span>
)}
<div className="inline-flex items-center gap-2">
{isLoading ? (
<Skeleton.Node active style={{ width: 200, height: 20 }} />
) : (
<span className="inline-flex text-sm text-gray-700">
Showing {rangeLabel} of {totalCount} results
</span>
)}
<AntButton
type="default"
icon={<SyncOutlined spin={isButtonLoading} />}
onClick={handleRefresh}
disabled={isButtonLoading}
title="Fetch data"
>
{isButtonLoading ? "Fetching" : "Fetch"}
</AntButton>
</div>
<div className="inline-flex items-center gap-2">
{isLoading || isFetching ? (
{isLoading ? (
<Skeleton.Node active style={{ width: 74, height: 20 }} />
) : (
<span className="text-sm text-gray-700">
@ -623,24 +644,24 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
</span>
)}
{isLoading || isFetching ? (
{isLoading ? (
<Skeleton.Button active size="small" style={{ width: 84, height: 30 }} />
) : (
<button
onClick={() => table.previousPage()}
disabled={isLoading || isFetching || !table.getCanPreviousPage()}
disabled={isLoading || !table.getCanPreviousPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
)}
{isLoading || isFetching ? (
{isLoading ? (
<Skeleton.Button active size="small" style={{ width: 58, height: 30 }} />
) : (
<button
onClick={() => table.nextPage()}
disabled={isLoading || isFetching || !table.getCanNextPage()}
disabled={isLoading || !table.getCanNextPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
@ -725,7 +746,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
))}
</TableHead>
<TableBody>
{isLoading || isFetching ? (
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">