**Related issue:** Resolves #48641 ## Description Deleting the only policy on a paginated page (e.g., 21 policies, with 1 on page 2) left the user stranded on a now-empty page showing the "No policies" empty state. The policies list now steps back to the previous page when a delete empties the current page. **Before:** delete last policy on page 2 → empty state. **After:** delete last policy on page 2 → list returns to page 1. ### Screen recording demonstrating the fix https://github.com/user-attachments/assets/ae106a50-7f9b-4080-a19c-53e0c60fff48 # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Server-side paginated tables now recover from empty states after deleting the last row on a page by redirecting to the last page that still has data. * Improved empty-state pagination handling for out-of-range pages, loading states, and cases where the total row count is known (including zero), avoiding unnecessary or repeated navigation. * Simplified the empty-state pagination UI to render only the empty component. * **Tests** * Expanded regression test coverage for server-side pagination edge cases and page-correction behavior to prevent future regressions. * **Style** * Removed unused empty/previous-button styling rules in the table container. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
175 lines
5.2 KiB
TypeScript
175 lines
5.2 KiB
TypeScript
import React, { useState } from "react";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
|
|
import TableContainer, { ITableQueryData } from "./TableContainer";
|
|
|
|
const COLUMN_CONFIGS = [
|
|
{
|
|
title: "Name",
|
|
Header: "Name",
|
|
accessor: "name",
|
|
disableSortBy: true,
|
|
},
|
|
];
|
|
|
|
const EmptyComponent = () => <div>No items found</div>;
|
|
|
|
// pageIndex requested by the most recent onQueryChange call.
|
|
const lastRequestedPageIndex = (onQueryChange: jest.Mock) => {
|
|
const { calls } = onQueryChange.mock;
|
|
return (calls[calls.length - 1][0] as ITableQueryData).pageIndex;
|
|
};
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
// Simulates a real parent: pageIndex is URL-driven, and the data shown is
|
|
// derived from the requested page. Used to exercise the page-correction effect
|
|
// end-to-end (and guard against navigation feedback loops).
|
|
const ServerPaginatedTable = ({
|
|
initialPage,
|
|
totalCount,
|
|
}: {
|
|
initialPage: number;
|
|
totalCount: number;
|
|
}) => {
|
|
const [page, setPage] = useState(initialPage);
|
|
const start = page * PAGE_SIZE;
|
|
const rows = Array.from({
|
|
length: Math.max(0, Math.min(PAGE_SIZE, totalCount - start)),
|
|
}).map((_, i) => ({ name: `row ${start + i}` }));
|
|
|
|
return (
|
|
<TableContainer
|
|
columnConfigs={COLUMN_CONFIGS}
|
|
data={rows}
|
|
isLoading={false}
|
|
emptyComponent={EmptyComponent}
|
|
showMarkAllPages={false}
|
|
isAllPagesSelected={false}
|
|
pageIndex={page}
|
|
onQueryChange={(q: ITableQueryData) => setPage(q.pageIndex)}
|
|
defaultSortHeader="name"
|
|
/>
|
|
);
|
|
};
|
|
|
|
describe("TableContainer - server-side empty page", () => {
|
|
it("navigates back to the last page with data when a non-first page is empty", async () => {
|
|
const onQueryChange = jest.fn();
|
|
|
|
render(
|
|
<TableContainer
|
|
columnConfigs={COLUMN_CONFIGS}
|
|
data={[]}
|
|
isLoading={false}
|
|
emptyComponent={EmptyComponent}
|
|
showMarkAllPages={false}
|
|
isAllPagesSelected={false}
|
|
pageIndex={1}
|
|
totalCount={20}
|
|
onQueryChange={onQueryChange}
|
|
defaultSortHeader="name"
|
|
/>
|
|
);
|
|
|
|
// The empty page (index 1) is not a resting state: the table should request
|
|
// the last page that actually has data (index 0 here).
|
|
await waitFor(() => {
|
|
expect(onQueryChange).toHaveBeenCalled();
|
|
expect(lastRequestedPageIndex(onQueryChange)).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("shows the empty state and stays put on the first page", async () => {
|
|
const onQueryChange = jest.fn();
|
|
|
|
render(
|
|
<TableContainer
|
|
columnConfigs={COLUMN_CONFIGS}
|
|
data={[]}
|
|
isLoading={false}
|
|
emptyComponent={EmptyComponent}
|
|
showMarkAllPages={false}
|
|
isAllPagesSelected={false}
|
|
pageIndex={0}
|
|
totalCount={0}
|
|
onQueryChange={onQueryChange}
|
|
defaultSortHeader="name"
|
|
/>
|
|
);
|
|
|
|
expect(await screen.findByText("No items found")).toBeInTheDocument();
|
|
await waitFor(() => {
|
|
expect(lastRequestedPageIndex(onQueryChange)).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("does not redirect while the page is still loading", () => {
|
|
const onQueryChange = jest.fn();
|
|
|
|
render(
|
|
<TableContainer
|
|
columnConfigs={COLUMN_CONFIGS}
|
|
data={[]}
|
|
isLoading
|
|
emptyComponent={EmptyComponent}
|
|
showMarkAllPages={false}
|
|
isAllPagesSelected={false}
|
|
pageIndex={1}
|
|
totalCount={20}
|
|
onQueryChange={onQueryChange}
|
|
defaultSortHeader="name"
|
|
/>
|
|
);
|
|
|
|
// While loading we can't know the page is truly empty, so the requested
|
|
// page must never be corrected away from the one that was asked for.
|
|
const requestedPageIndexes = onQueryChange.mock.calls.map(
|
|
(call) => (call[0] as ITableQueryData).pageIndex
|
|
);
|
|
expect(requestedPageIndexes).not.toContain(0);
|
|
});
|
|
|
|
// Regression: entering an out-of-range page via the URL must settle on the
|
|
// last page with data without looping.
|
|
it("settles on the last page with data when entered on an out-of-range page", async () => {
|
|
// 21 rows -> pages 0 (20 rows) and 1 (1 row); pages >= 2 are empty.
|
|
render(<ServerPaginatedTable initialPage={3} totalCount={21} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("row 20")).toBeInTheDocument();
|
|
});
|
|
expect(screen.queryByText("No items found")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("jumps straight to the first page when the total count is a known zero", async () => {
|
|
const onQueryChange = jest.fn();
|
|
|
|
render(
|
|
<TableContainer
|
|
columnConfigs={COLUMN_CONFIGS}
|
|
data={[]}
|
|
isLoading={false}
|
|
emptyComponent={EmptyComponent}
|
|
showMarkAllPages={false}
|
|
isAllPagesSelected={false}
|
|
pageIndex={3}
|
|
totalCount={0}
|
|
onQueryChange={onQueryChange}
|
|
defaultSortHeader="name"
|
|
/>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onQueryChange).toHaveBeenCalled();
|
|
expect(lastRequestedPageIndex(onQueryChange)).toBe(0);
|
|
});
|
|
// A known-empty count should jump straight to page 0, not step 3 -> 2 -> 1.
|
|
const requestedPageIndexes = onQueryChange.mock.calls.map(
|
|
(call) => (call[0] as ITableQueryData).pageIndex
|
|
);
|
|
expect(requestedPageIndexes).not.toContain(2);
|
|
expect(requestedPageIndexes).not.toContain(1);
|
|
});
|
|
});
|