fix(ui): show loading state instead of stale rows while a table search is pending

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
ryan 2026-09-11 01:30:21 +00:00
parent 4fbe2276a1
commit d36cdcb8f9
8 changed files with 59 additions and 7 deletions

View file

@ -152,6 +152,28 @@ describe("useResourceList", () => {
await waitFor(() => expect(lastCall().page_size).toBe(25));
});
it("reports loading while a new search request is still pending", async () => {
let resolveSecond: ((value: ResourceListPage<Row>) => void) | undefined;
const fetchPage = vi.fn((query: ResourceListQuery) => {
calls.push(query);
if (calls.length === 1) return Promise.resolve(page([{ id: "a" }], 3));
return new Promise<ResourceListPage<Row>>((resolve) => {
resolveSecond = resolve;
});
});
const { result } = renderList({ fetchPage });
await waitFor(() => expect(result.current.rows).toEqual([{ id: "a" }]));
expect(result.current.isLoading).toBe(false);
act(() => result.current.onSearchChange("zzz"));
await waitFor(() => expect(lastCall().q).toBe("zzz"));
expect(result.current.isLoading).toBe(true);
act(() => resolveSecond?.(page([], 0)));
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.rows).toEqual([]);
});
it("surfaces a failed page as an error instead of empty rows", async () => {
const fetchPage = vi.fn(() => Promise.reject(new Error("boom")));
const { result } = renderList({ fetchPage });

View file

@ -86,7 +86,7 @@ export function useResourceList<TRow>(options: UseResourceListOptions<TRow>): Re
enabled,
placeholderData: (previous) => previous,
};
const { data, isLoading, isFetching, error, refetch: refetchQuery } = useQuery(queryOptions);
const { data, isLoading, isPlaceholderData, isFetching, error, refetch: refetchQuery } = useQuery(queryOptions);
const toFirstPage = useCallback(() => setPagination((previous) => ({ ...previous, pageIndex: 0 })), []);
@ -123,7 +123,7 @@ export function useResourceList<TRow>(options: UseResourceListOptions<TRow>): Re
return {
rows,
rowCount: data?.meta.total_count ?? 0,
isLoading,
isLoading: isLoading || isPlaceholderData,
isFetching,
error,
refetch,

View file

@ -344,5 +344,16 @@ describe("ViewUserDashboard", () => {
expect(latest[4]).toBeNull();
expect(latest[2]).toBe(1);
});
it("replaces the previous rows with the loading state while the search request is pending", async () => {
renderDashboard();
expect(await screen.findByText("test@example.com")).toBeInTheDocument();
userListCall.mockReturnValue(new Promise(() => undefined));
fireEvent.change(screen.getByPlaceholderText("Search by email or ID…"), { target: { value: "zzznomatch" } });
expect(await screen.findByText("Loading users…")).toBeInTheDocument();
expect(screen.queryByText("test@example.com")).not.toBeInTheDocument();
});
});
});

View file

@ -295,7 +295,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
<UsersTable
data={users}
rowCount={totalUserCount}
isLoading={userListQuery.isLoading}
isLoading={userListQuery.isLoading || userListQuery.isPlaceholderData}
possibleUIRoles={possibleUIRoles}
teams={teams}
sorting={sorting}

View file

@ -134,6 +134,14 @@ it("shows a loading state on initial load and hides the data", () => {
expect(screen.queryByText("Acme Team")).not.toBeInTheDocument();
});
it("replaces the previous rows with the loading state while a new search is pending", () => {
mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], {}, { isPlaceholderData: true, isFetching: true }));
renderTable();
expect(screen.getByText("Loading teams...")).toBeInTheDocument();
expect(screen.queryByText("Acme Team")).not.toBeInTheDocument();
});
describe("sort contract only backend-sortable columns are sortable", () => {
it("requests the default created_at descending sort on first render", () => {
renderTable();

View file

@ -83,7 +83,8 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
const {
data: teamsResponse,
isPending: isLoading,
isPending,
isPlaceholderData,
isFetching,
refetch,
} = useTeamsTable(tablePagination.pageIndex + 1, tablePagination.pageSize, teamListOptions);
@ -161,7 +162,7 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
onColumnFiltersChange={handleColumnFiltersChange}
enableColumnResizing
columnResizeMode="onChange"
isLoading={isLoading}
isLoading={isPending || isPlaceholderData}
loadingMessage="Loading teams..."
noDataMessage="No teams found"
fillHeight

View file

@ -289,6 +289,15 @@ it("should show a loading state on the initial load and hide the data", () => {
expect(screen.queryByText("Test Key Alias")).not.toBeInTheDocument();
});
it("replaces the previous rows with the loading state while a new search is pending", () => {
mockUseKeys.mockReturnValue(keysResult([mockKey], {}, { isPlaceholderData: true, isFetching: true }));
renderWithProviders(<VirtualKeysTable />);
expect(screen.getByText("Loading keys...")).toBeInTheDocument();
expect(screen.queryByText("Test Key Alias")).not.toBeInTheDocument();
});
it("should show 'No keys found' message when the key list is empty", () => {
mockUseKeys.mockReturnValue(keysResult([]));

View file

@ -128,7 +128,8 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
const {
data: keys,
isPending: isLoading,
isPending,
isPlaceholderData,
isFetching,
refetch,
} = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize, keyListOptions);
@ -280,7 +281,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
onColumnFiltersChange={handleColumnFiltersChange}
enableColumnResizing
columnResizeMode="onChange"
isLoading={isLoading}
isLoading={isPending || isPlaceholderData}
loadingMessage="Loading keys..."
noDataMessage="No keys found"
fillHeight