Skip to content

Commit 28eca2e

Browse files
authored
fix: create centralized PaginationContainer component (#10967)
* chore: add Pagination component, add new test, and update other pagination tests * fix: add back temp spacing for WorkspacesPageView * chore: update AuditPage to use Pagination * chore: update UsersPage to use Pagination * refactor: move parts of Pagination into WorkspacesPageView * fix: handle empty states for pagination labels better * docs: rewrite comment for clarity * refactor: rename components/properties for clarity * fix: rename component files for clarity * chore: add story for PaginationContainer * chore: rename story for clarity * fix: handle undefined case better * fix: update imports for PaginationContainer mocks * fix: update story values for clarity * fix: update scroll logic to go to the bottom instead of the top * fix: update mock setup for test * fix: update stories * fix: remove scrolling functionality * fix: remove deprecated property * refactor: rename prop * fix: remove debounce flake
1 parent d9a1695 commit 28eca2e

18 files changed

+484
-198
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* @file Mock input props for use with PaginationContainer's tests and stories.
3+
*
4+
* Had to split this off into a separate file because housing these in the test
5+
* file and then importing them from the stories file was causing Chromatic's
6+
* Vite test environment to break
7+
*/
8+
import type { PaginationResult } from "./PaginationContainer";
9+
10+
type ResultBase = Omit<
11+
PaginationResult,
12+
"isPreviousData" | "currentOffsetStart" | "totalRecords" | "totalPages"
13+
>;
14+
15+
export const mockPaginationResultBase: ResultBase = {
16+
isSuccess: false,
17+
currentPage: 1,
18+
limit: 25,
19+
hasNextPage: false,
20+
hasPreviousPage: false,
21+
goToPreviousPage: () => {},
22+
goToNextPage: () => {},
23+
goToFirstPage: () => {},
24+
onPageChange: () => {},
25+
};
26+
27+
export const mockInitialRenderResult: PaginationResult = {
28+
...mockPaginationResultBase,
29+
isSuccess: false,
30+
isPreviousData: false,
31+
currentOffsetStart: undefined,
32+
hasNextPage: false,
33+
hasPreviousPage: false,
34+
totalRecords: undefined,
35+
totalPages: undefined,
36+
};
37+
38+
export const mockSuccessResult: PaginationResult = {
39+
...mockPaginationResultBase,
40+
isSuccess: true,
41+
isPreviousData: false,
42+
currentOffsetStart: 1,
43+
totalPages: 1,
44+
totalRecords: 4,
45+
};
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import type {
2+
ComponentProps,
3+
FC,
4+
HTMLAttributes,
5+
PropsWithChildren,
6+
} from "react";
7+
import { PaginationContainer } from "./PaginationContainer";
8+
import type { Meta, StoryObj } from "@storybook/react";
9+
10+
import {
11+
mockPaginationResultBase,
12+
mockInitialRenderResult,
13+
} from "./PaginationContainer.mocks";
14+
15+
// Filtering out optional <div> props to give better auto-complete experience
16+
type EssentialComponent = FC<
17+
Omit<
18+
ComponentProps<typeof PaginationContainer>,
19+
keyof HTMLAttributes<HTMLDivElement>
20+
> &
21+
PropsWithChildren
22+
>;
23+
24+
const meta: Meta<EssentialComponent> = {
25+
title: "components/PaginationContainer",
26+
component: PaginationContainer,
27+
args: {
28+
paginationUnitLabel: "puppies",
29+
children: <div>Put any content here</div>,
30+
},
31+
};
32+
33+
export default meta;
34+
type Story = StoryObj<EssentialComponent>;
35+
36+
export const FirstPageBeforeFetch: Story = {
37+
args: {
38+
query: mockInitialRenderResult,
39+
},
40+
};
41+
42+
export const FirstPageWithData: Story = {
43+
args: {
44+
query: {
45+
...mockPaginationResultBase,
46+
isSuccess: true,
47+
currentPage: 1,
48+
currentOffsetStart: 1,
49+
totalRecords: 100,
50+
totalPages: 4,
51+
hasPreviousPage: false,
52+
hasNextPage: true,
53+
isPreviousData: false,
54+
},
55+
},
56+
};
57+
58+
export const FirstPageWithLittleData: Story = {
59+
args: {
60+
query: {
61+
...mockPaginationResultBase,
62+
isSuccess: true,
63+
currentPage: 1,
64+
currentOffsetStart: 1,
65+
totalRecords: 7,
66+
totalPages: 1,
67+
hasPreviousPage: false,
68+
hasNextPage: false,
69+
isPreviousData: false,
70+
},
71+
},
72+
};
73+
74+
export const FirstPageWithNoData: Story = {
75+
args: {
76+
query: {
77+
...mockPaginationResultBase,
78+
isSuccess: true,
79+
currentPage: 1,
80+
currentOffsetStart: 1,
81+
totalRecords: 0,
82+
totalPages: 0,
83+
hasPreviousPage: false,
84+
hasNextPage: false,
85+
isPreviousData: false,
86+
},
87+
},
88+
};
89+
90+
export const TransitionFromFirstToSecondPage: Story = {
91+
args: {
92+
query: {
93+
...mockPaginationResultBase,
94+
isSuccess: true,
95+
currentPage: 2,
96+
currentOffsetStart: 26,
97+
totalRecords: 100,
98+
totalPages: 4,
99+
hasPreviousPage: false,
100+
hasNextPage: false,
101+
isPreviousData: true,
102+
},
103+
children: <div>Previous data from page 1</div>,
104+
},
105+
};
106+
107+
export const SecondPageWithData: Story = {
108+
args: {
109+
query: {
110+
...mockPaginationResultBase,
111+
isSuccess: true,
112+
currentPage: 2,
113+
currentOffsetStart: 26,
114+
totalRecords: 100,
115+
totalPages: 4,
116+
hasPreviousPage: true,
117+
hasNextPage: true,
118+
isPreviousData: false,
119+
},
120+
children: <div>New data for page 2</div>,
121+
},
122+
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { type FC, type HTMLAttributes } from "react";
2+
import { type PaginationResultInfo } from "hooks/usePaginatedQuery";
3+
import { PaginationWidgetBase } from "./PaginationWidgetBase";
4+
import { PaginationHeader } from "./PaginationHeader";
5+
6+
export type PaginationResult = PaginationResultInfo & {
7+
isPreviousData: boolean;
8+
};
9+
10+
type PaginationProps = HTMLAttributes<HTMLDivElement> & {
11+
query: PaginationResult;
12+
paginationUnitLabel: string;
13+
};
14+
15+
export const PaginationContainer: FC<PaginationProps> = ({
16+
children,
17+
query,
18+
paginationUnitLabel,
19+
...delegatedProps
20+
}) => {
21+
return (
22+
<>
23+
<PaginationHeader
24+
limit={query.limit}
25+
totalRecords={query.totalRecords}
26+
currentOffsetStart={query.currentOffsetStart}
27+
paginationUnitLabel={paginationUnitLabel}
28+
/>
29+
30+
<div
31+
css={{
32+
display: "flex",
33+
flexFlow: "column nowrap",
34+
rowGap: "16px",
35+
}}
36+
{...delegatedProps}
37+
>
38+
{children}
39+
40+
{query.isSuccess && (
41+
<PaginationWidgetBase
42+
totalRecords={query.totalRecords}
43+
currentPage={query.currentPage}
44+
pageSize={query.limit}
45+
onPageChange={query.onPageChange}
46+
hasPreviousPage={query.hasPreviousPage}
47+
hasNextPage={query.hasNextPage}
48+
/>
49+
)}
50+
</div>
51+
</>
52+
);
53+
};
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { type FC } from "react";
2+
import { useTheme } from "@emotion/react";
3+
import Skeleton from "@mui/material/Skeleton";
4+
5+
type PaginationHeaderProps = {
6+
paginationUnitLabel: string;
7+
limit: number;
8+
totalRecords: number | undefined;
9+
currentOffsetStart: number | undefined;
10+
};
11+
12+
export const PaginationHeader: FC<PaginationHeaderProps> = ({
13+
paginationUnitLabel,
14+
limit,
15+
totalRecords,
16+
currentOffsetStart,
17+
}) => {
18+
const theme = useTheme();
19+
20+
return (
21+
<div
22+
css={{
23+
display: "flex",
24+
flexFlow: "row nowrap",
25+
alignItems: "center",
26+
margin: 0,
27+
fontSize: "13px",
28+
paddingBottom: "8px",
29+
color: theme.palette.text.secondary,
30+
height: "36px", // The size of a small button
31+
"& strong": {
32+
color: theme.palette.text.primary,
33+
},
34+
}}
35+
>
36+
{totalRecords !== undefined ? (
37+
<>
38+
{/**
39+
* Have to put text content in divs so that flexbox doesn't scramble
40+
* the inner text nodes up
41+
*/}
42+
{totalRecords === 0 && <div>No records available</div>}
43+
44+
{totalRecords !== 0 && currentOffsetStart !== undefined && (
45+
<div>
46+
Showing {paginationUnitLabel}{" "}
47+
<strong>
48+
{currentOffsetStart}&ndash;
49+
{currentOffsetStart +
50+
Math.min(limit - 1, totalRecords - currentOffsetStart)}
51+
</strong>{" "}
52+
(<strong>{totalRecords.toLocaleString()}</strong>{" "}
53+
{paginationUnitLabel} total)
54+
</div>
55+
)}
56+
</>
57+
) : (
58+
<Skeleton variant="text" width={160} height={16} />
59+
)}
60+
</div>
61+
);
62+
};

site/src/components/PaginationWidget/PaginationWidgetBase.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,15 @@ describe(PaginationWidgetBase.name, () => {
7474
expect(prevButton).not.toBeDisabled();
7575
expect(prevButton).toHaveAttribute("aria-disabled", "false");
7676

77+
await userEvent.click(prevButton);
78+
expect(onPageChange).toHaveBeenCalledTimes(1);
79+
7780
expect(nextButton).not.toBeDisabled();
7881
expect(nextButton).toHaveAttribute("aria-disabled", "false");
7982

80-
await userEvent.click(prevButton);
8183
await userEvent.click(nextButton);
8284
expect(onPageChange).toHaveBeenCalledTimes(2);
85+
8386
unmount();
8487
}
8588
});

site/src/components/PaginationWidget/PaginationWidgetBase.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@ export type PaginationWidgetBaseProps = {
1212
pageSize: number;
1313
totalRecords: number;
1414
onPageChange: (newPage: number) => void;
15+
16+
hasPreviousPage?: boolean;
17+
hasNextPage?: boolean;
1518
};
1619

1720
export const PaginationWidgetBase = ({
1821
currentPage,
1922
pageSize,
2023
totalRecords,
2124
onPageChange,
25+
hasPreviousPage,
26+
hasNextPage,
2227
}: PaginationWidgetBaseProps) => {
2328
const theme = useTheme();
2429
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
@@ -28,8 +33,11 @@ export const PaginationWidgetBase = ({
2833
return null;
2934
}
3035

31-
const onFirstPage = currentPage <= 1;
32-
const onLastPage = currentPage >= totalPages;
36+
const currentPageOffset = (currentPage - 1) * pageSize;
37+
const isPrevDisabled = !(hasPreviousPage ?? currentPage > 1);
38+
const isNextDisabled = !(
39+
hasNextPage ?? pageSize + currentPageOffset < totalRecords
40+
);
3341

3442
return (
3543
<div
@@ -38,16 +46,16 @@ export const PaginationWidgetBase = ({
3846
alignItems: "center",
3947
display: "flex",
4048
flexDirection: "row",
41-
padding: "20px",
49+
padding: "0 20px",
4250
columnGap: "6px",
4351
}}
4452
>
4553
<PaginationNavButton
4654
disabledMessage="You are already on the first page"
47-
disabled={onFirstPage}
55+
disabled={isPrevDisabled}
4856
aria-label="Previous page"
4957
onClick={() => {
50-
if (!onFirstPage) {
58+
if (!isPrevDisabled) {
5159
onPageChange(currentPage - 1);
5260
}
5361
}}
@@ -70,11 +78,11 @@ export const PaginationWidgetBase = ({
7078
)}
7179

7280
<PaginationNavButton
73-
disabledMessage="You're already on the last page"
74-
disabled={onLastPage}
81+
disabledMessage="You are already on the last page"
82+
disabled={isNextDisabled}
7583
aria-label="Next page"
7684
onClick={() => {
77-
if (!onLastPage) {
85+
if (!isNextDisabled) {
7886
onPageChange(currentPage + 1);
7987
}
8088
}}

0 commit comments

Comments
 (0)