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
This commit is contained in:
Michael Smith 2023-12-02 17:37:59 -05:00 committed by GitHub
parent d9a169556a
commit 28eca2e53f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 490 additions and 204 deletions

View File

@ -0,0 +1,45 @@
/**
* @file Mock input props for use with PaginationContainer's tests and stories.
*
* Had to split this off into a separate file because housing these in the test
* file and then importing them from the stories file was causing Chromatic's
* Vite test environment to break
*/
import type { PaginationResult } from "./PaginationContainer";
type ResultBase = Omit<
PaginationResult,
"isPreviousData" | "currentOffsetStart" | "totalRecords" | "totalPages"
>;
export const mockPaginationResultBase: ResultBase = {
isSuccess: false,
currentPage: 1,
limit: 25,
hasNextPage: false,
hasPreviousPage: false,
goToPreviousPage: () => {},
goToNextPage: () => {},
goToFirstPage: () => {},
onPageChange: () => {},
};
export const mockInitialRenderResult: PaginationResult = {
...mockPaginationResultBase,
isSuccess: false,
isPreviousData: false,
currentOffsetStart: undefined,
hasNextPage: false,
hasPreviousPage: false,
totalRecords: undefined,
totalPages: undefined,
};
export const mockSuccessResult: PaginationResult = {
...mockPaginationResultBase,
isSuccess: true,
isPreviousData: false,
currentOffsetStart: 1,
totalPages: 1,
totalRecords: 4,
};

View File

@ -0,0 +1,122 @@
import type {
ComponentProps,
FC,
HTMLAttributes,
PropsWithChildren,
} from "react";
import { PaginationContainer } from "./PaginationContainer";
import type { Meta, StoryObj } from "@storybook/react";
import {
mockPaginationResultBase,
mockInitialRenderResult,
} from "./PaginationContainer.mocks";
// Filtering out optional <div> props to give better auto-complete experience
type EssentialComponent = FC<
Omit<
ComponentProps<typeof PaginationContainer>,
keyof HTMLAttributes<HTMLDivElement>
> &
PropsWithChildren
>;
const meta: Meta<EssentialComponent> = {
title: "components/PaginationContainer",
component: PaginationContainer,
args: {
paginationUnitLabel: "puppies",
children: <div>Put any content here</div>,
},
};
export default meta;
type Story = StoryObj<EssentialComponent>;
export const FirstPageBeforeFetch: Story = {
args: {
query: mockInitialRenderResult,
},
};
export const FirstPageWithData: Story = {
args: {
query: {
...mockPaginationResultBase,
isSuccess: true,
currentPage: 1,
currentOffsetStart: 1,
totalRecords: 100,
totalPages: 4,
hasPreviousPage: false,
hasNextPage: true,
isPreviousData: false,
},
},
};
export const FirstPageWithLittleData: Story = {
args: {
query: {
...mockPaginationResultBase,
isSuccess: true,
currentPage: 1,
currentOffsetStart: 1,
totalRecords: 7,
totalPages: 1,
hasPreviousPage: false,
hasNextPage: false,
isPreviousData: false,
},
},
};
export const FirstPageWithNoData: Story = {
args: {
query: {
...mockPaginationResultBase,
isSuccess: true,
currentPage: 1,
currentOffsetStart: 1,
totalRecords: 0,
totalPages: 0,
hasPreviousPage: false,
hasNextPage: false,
isPreviousData: false,
},
},
};
export const TransitionFromFirstToSecondPage: Story = {
args: {
query: {
...mockPaginationResultBase,
isSuccess: true,
currentPage: 2,
currentOffsetStart: 26,
totalRecords: 100,
totalPages: 4,
hasPreviousPage: false,
hasNextPage: false,
isPreviousData: true,
},
children: <div>Previous data from page 1</div>,
},
};
export const SecondPageWithData: Story = {
args: {
query: {
...mockPaginationResultBase,
isSuccess: true,
currentPage: 2,
currentOffsetStart: 26,
totalRecords: 100,
totalPages: 4,
hasPreviousPage: true,
hasNextPage: true,
isPreviousData: false,
},
children: <div>New data for page 2</div>,
},
};

View File

@ -0,0 +1,53 @@
import { type FC, type HTMLAttributes } from "react";
import { type PaginationResultInfo } from "hooks/usePaginatedQuery";
import { PaginationWidgetBase } from "./PaginationWidgetBase";
import { PaginationHeader } from "./PaginationHeader";
export type PaginationResult = PaginationResultInfo & {
isPreviousData: boolean;
};
type PaginationProps = HTMLAttributes<HTMLDivElement> & {
query: PaginationResult;
paginationUnitLabel: string;
};
export const PaginationContainer: FC<PaginationProps> = ({
children,
query,
paginationUnitLabel,
...delegatedProps
}) => {
return (
<>
<PaginationHeader
limit={query.limit}
totalRecords={query.totalRecords}
currentOffsetStart={query.currentOffsetStart}
paginationUnitLabel={paginationUnitLabel}
/>
<div
css={{
display: "flex",
flexFlow: "column nowrap",
rowGap: "16px",
}}
{...delegatedProps}
>
{children}
{query.isSuccess && (
<PaginationWidgetBase
totalRecords={query.totalRecords}
currentPage={query.currentPage}
pageSize={query.limit}
onPageChange={query.onPageChange}
hasPreviousPage={query.hasPreviousPage}
hasNextPage={query.hasNextPage}
/>
)}
</div>
</>
);
};

View File

@ -0,0 +1,62 @@
import { type FC } from "react";
import { useTheme } from "@emotion/react";
import Skeleton from "@mui/material/Skeleton";
type PaginationHeaderProps = {
paginationUnitLabel: string;
limit: number;
totalRecords: number | undefined;
currentOffsetStart: number | undefined;
};
export const PaginationHeader: FC<PaginationHeaderProps> = ({
paginationUnitLabel,
limit,
totalRecords,
currentOffsetStart,
}) => {
const theme = useTheme();
return (
<div
css={{
display: "flex",
flexFlow: "row nowrap",
alignItems: "center",
margin: 0,
fontSize: "13px",
paddingBottom: "8px",
color: theme.palette.text.secondary,
height: "36px", // The size of a small button
"& strong": {
color: theme.palette.text.primary,
},
}}
>
{totalRecords !== undefined ? (
<>
{/**
* Have to put text content in divs so that flexbox doesn't scramble
* the inner text nodes up
*/}
{totalRecords === 0 && <div>No records available</div>}
{totalRecords !== 0 && currentOffsetStart !== undefined && (
<div>
Showing {paginationUnitLabel}{" "}
<strong>
{currentOffsetStart}&ndash;
{currentOffsetStart +
Math.min(limit - 1, totalRecords - currentOffsetStart)}
</strong>{" "}
(<strong>{totalRecords.toLocaleString()}</strong>{" "}
{paginationUnitLabel} total)
</div>
)}
</>
) : (
<Skeleton variant="text" width={160} height={16} />
)}
</div>
);
};

View File

@ -74,12 +74,15 @@ describe(PaginationWidgetBase.name, () => {
expect(prevButton).not.toBeDisabled();
expect(prevButton).toHaveAttribute("aria-disabled", "false");
await userEvent.click(prevButton);
expect(onPageChange).toHaveBeenCalledTimes(1);
expect(nextButton).not.toBeDisabled();
expect(nextButton).toHaveAttribute("aria-disabled", "false");
await userEvent.click(prevButton);
await userEvent.click(nextButton);
expect(onPageChange).toHaveBeenCalledTimes(2);
unmount();
}
});

View File

@ -12,6 +12,9 @@ export type PaginationWidgetBaseProps = {
pageSize: number;
totalRecords: number;
onPageChange: (newPage: number) => void;
hasPreviousPage?: boolean;
hasNextPage?: boolean;
};
export const PaginationWidgetBase = ({
@ -19,6 +22,8 @@ export const PaginationWidgetBase = ({
pageSize,
totalRecords,
onPageChange,
hasPreviousPage,
hasNextPage,
}: PaginationWidgetBaseProps) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
@ -28,8 +33,11 @@ export const PaginationWidgetBase = ({
return null;
}
const onFirstPage = currentPage <= 1;
const onLastPage = currentPage >= totalPages;
const currentPageOffset = (currentPage - 1) * pageSize;
const isPrevDisabled = !(hasPreviousPage ?? currentPage > 1);
const isNextDisabled = !(
hasNextPage ?? pageSize + currentPageOffset < totalRecords
);
return (
<div
@ -38,16 +46,16 @@ export const PaginationWidgetBase = ({
alignItems: "center",
display: "flex",
flexDirection: "row",
padding: "20px",
padding: "0 20px",
columnGap: "6px",
}}
>
<PaginationNavButton
disabledMessage="You are already on the first page"
disabled={onFirstPage}
disabled={isPrevDisabled}
aria-label="Previous page"
onClick={() => {
if (!onFirstPage) {
if (!isPrevDisabled) {
onPageChange(currentPage - 1);
}
}}
@ -70,11 +78,11 @@ export const PaginationWidgetBase = ({
)}
<PaginationNavButton
disabledMessage="You're already on the last page"
disabled={onLastPage}
disabledMessage="You are already on the last page"
disabled={isNextDisabled}
aria-label="Next page"
onClick={() => {
if (!onLastPage) {
if (!isNextDisabled) {
onPageChange(currentPage + 1);
}
}}

View File

@ -1,4 +1,4 @@
import { renderHook } from "@testing-library/react";
import { renderHook, waitFor } from "@testing-library/react";
import { useDebouncedFunction, useDebouncedValue } from "./debounce";
beforeAll(() => {
@ -45,7 +45,6 @@ describe(`${useDebouncedValue.name}`, () => {
const { result } = renderDebouncedValue(value, 2000);
expect(result.current).toBe(value);
expect.hasAssertions();
});
it("Should not immediately resync state as the hook re-renders with new value argument", async () => {
@ -64,7 +63,6 @@ describe(`${useDebouncedValue.name}`, () => {
await jest.advanceTimersByTimeAsync(time - 100);
expect(result.current).toEqual(0);
expect.hasAssertions();
});
it("Should resync after specified milliseconds pass with no change to arguments", async () => {
@ -76,9 +74,7 @@ describe(`${useDebouncedValue.name}`, () => {
rerender({ value: !initialValue, time });
await jest.runAllTimersAsync();
expect(result.current).toEqual(true);
expect.hasAssertions();
await waitFor(() => expect(result.current).toEqual(true));
});
});
@ -97,7 +93,6 @@ describe(`${useDebouncedFunction.name}`, () => {
expect(oldDebounced).toBe(newDebounced);
expect(oldCancel).toBe(newCancel);
expect.hasAssertions();
});
it("Resets any pending debounces if the timer argument changes", async () => {
@ -117,7 +112,6 @@ describe(`${useDebouncedFunction.name}`, () => {
await jest.runAllTimersAsync();
expect(count).toEqual(0);
expect.hasAssertions();
});
});
@ -132,7 +126,6 @@ describe(`${useDebouncedFunction.name}`, () => {
await jest.runOnlyPendingTimersAsync();
expect(value).toBe(true);
expect.hasAssertions();
});
it("Always uses the most recent callback argument passed in (even if it switches while a debounce is queued)", async () => {
@ -153,7 +146,6 @@ describe(`${useDebouncedFunction.name}`, () => {
await jest.runAllTimersAsync();
expect(count).toEqual(9999);
expect.hasAssertions();
});
it("Should reset the debounce timer with repeated calls to the method", async () => {
@ -170,7 +162,6 @@ describe(`${useDebouncedFunction.name}`, () => {
await jest.runAllTimersAsync();
expect(count).toBe(1);
expect.hasAssertions();
});
});
@ -187,7 +178,6 @@ describe(`${useDebouncedFunction.name}`, () => {
await jest.runAllTimersAsync();
expect(count).toEqual(0);
expect.hasAssertions();
});
});
});

View File

@ -34,7 +34,7 @@ function render<
* There are a lot of test cases in this file. Scoping mocking to inner describe
* function calls to limit the cognitive load of maintaining all this stuff
*/
describe(`${usePaginatedQuery.name}`, () => {
describe(usePaginatedQuery.name, () => {
describe("queryPayload method", () => {
const mockQueryFn = jest.fn(() => Promise.resolve({ count: 0 }));

View File

@ -260,6 +260,7 @@ export function usePaginatedQuery<
hasPreviousPage,
totalRecords: totalRecords as number,
totalPages: totalPages as number,
currentOffsetStart: currentPageOffset + 1,
}
: {
isSuccess: false,
@ -267,6 +268,7 @@ export function usePaginatedQuery<
hasPreviousPage: false,
totalRecords: undefined,
totalPages: undefined,
currentOffsetStart: undefined,
}),
};
@ -293,7 +295,7 @@ function getParamsWithoutPage(params: URLSearchParams): URLSearchParams {
* All the pagination-properties for UsePaginatedQueryResult. Split up so that
* the types can be used separately in multiple spots.
*/
type PaginationResultInfo = {
export type PaginationResultInfo = {
currentPage: number;
limit: number;
onPageChange: (newPage: number) => void;
@ -307,6 +309,7 @@ type PaginationResultInfo = {
hasPreviousPage: false;
totalRecords: undefined;
totalPages: undefined;
currentOffsetStart: undefined;
}
| {
isSuccess: true;
@ -314,6 +317,7 @@ type PaginationResultInfo = {
hasPreviousPage: boolean;
totalRecords: number;
totalPages: number;
currentOffsetStart: number;
}
);

View File

@ -63,12 +63,9 @@ const AuditPage: FC = () => {
<AuditPageView
auditLogs={auditsQuery.data?.audit_logs}
count={auditsQuery.totalRecords}
page={auditsQuery.currentPage}
limit={auditsQuery.limit}
onPageChange={auditsQuery.onPageChange}
isNonInitialPage={isNonInitialPage(searchParams)}
isAuditLogVisible={isAuditLogVisible}
auditsQuery={auditsQuery}
error={auditsQuery.error}
filterProps={{
filter,

View File

@ -2,6 +2,12 @@ import { Meta, StoryObj } from "@storybook/react";
import { MockAuditLog, MockAuditLog2, MockUser } from "testHelpers/entities";
import { AuditPageView } from "./AuditPageView";
import { ComponentProps } from "react";
import {
mockInitialRenderResult,
mockSuccessResult,
} from "components/PaginationWidget/PaginationContainer.mocks";
import { type UsePaginatedQueryResult } from "hooks/usePaginatedQuery";
import {
MockMenu,
getDefaultFilterProps,
@ -28,9 +34,6 @@ const meta: Meta<typeof AuditPageView> = {
component: AuditPageView,
args: {
auditLogs: [MockAuditLog, MockAuditLog2],
count: 1000,
page: 1,
limit: 25,
isAuditLogVisible: true,
filterProps: defaultFilterProps,
},
@ -39,38 +42,53 @@ const meta: Meta<typeof AuditPageView> = {
export default meta;
type Story = StoryObj<typeof AuditPageView>;
export const AuditPage: Story = {};
export const Loading = {
export const AuditPage: Story = {
args: {
auditLogs: undefined,
count: undefined,
isNonInitialPage: false,
auditsQuery: mockSuccessResult,
},
};
export const EmptyPage = {
export const Loading: Story = {
args: {
auditLogs: undefined,
isNonInitialPage: false,
auditsQuery: mockInitialRenderResult,
},
};
export const EmptyPage: Story = {
args: {
auditLogs: [],
isNonInitialPage: true,
auditsQuery: {
...mockSuccessResult,
totalRecords: 0,
} as UsePaginatedQueryResult,
},
};
export const NoLogs = {
export const NoLogs: Story = {
args: {
auditLogs: [],
count: 0,
isNonInitialPage: false,
auditsQuery: {
...mockSuccessResult,
totalRecords: 0,
} as UsePaginatedQueryResult,
},
};
export const NotVisible = {
export const NotVisible: Story = {
args: {
isAuditLogVisible: false,
auditsQuery: mockInitialRenderResult,
},
};
export const AuditPageSmallViewport = {
export const AuditPageSmallViewport: Story = {
args: {
auditsQuery: mockSuccessResult,
},
parameters: {
chromatic: { viewports: [600] },
},

View File

@ -20,11 +20,11 @@ import { AuditHelpTooltip } from "./AuditHelpTooltip";
import { ComponentProps, FC } from "react";
import { AuditPaywall } from "./AuditPaywall";
import { AuditFilter } from "./AuditFilter";
import {
PaginationStatus,
TableToolbar,
} from "components/TableToolbar/TableToolbar";
import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase";
type PaginationResult,
PaginationContainer,
} from "components/PaginationWidget/PaginationContainer";
export const Language = {
title: "Audit",
@ -33,28 +33,25 @@ export const Language = {
export interface AuditPageViewProps {
auditLogs?: AuditLog[];
count?: number;
page: number;
limit: number;
onPageChange: (page: number) => void;
isNonInitialPage: boolean;
isAuditLogVisible: boolean;
error?: unknown;
filterProps: ComponentProps<typeof AuditFilter>;
auditsQuery: PaginationResult;
}
export const AuditPageView: FC<AuditPageViewProps> = ({
auditLogs,
count,
page,
limit,
onPageChange,
isNonInitialPage,
isAuditLogVisible,
error,
filterProps,
auditsQuery: paginationResult,
}) => {
const isLoading = (auditLogs === undefined || count === undefined) && !error;
const isLoading =
(auditLogs === undefined || paginationResult.totalRecords === undefined) &&
!error;
const isEmpty = !isLoading && auditLogs?.length === 0;
return (
@ -73,72 +70,63 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
<Cond condition={isAuditLogVisible}>
<AuditFilter {...filterProps} />
<TableToolbar>
<PaginationStatus
isLoading={Boolean(isLoading)}
showing={auditLogs?.length ?? 0}
total={count ?? 0}
label="audit logs"
/>
</TableToolbar>
<PaginationContainer
query={paginationResult}
paginationUnitLabel="logs"
>
<TableContainer>
<Table>
<TableBody>
<ChooseOne>
{/* Error condition should just show an empty table. */}
<Cond condition={Boolean(error)}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="An error occurred while loading audit logs" />
</TableCell>
</TableRow>
</Cond>
<TableContainer>
<Table>
<TableBody>
<ChooseOne>
{/* Error condition should just show an empty table. */}
<Cond condition={Boolean(error)}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="An error occurred while loading audit logs" />
</TableCell>
</TableRow>
</Cond>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Cond condition={isEmpty}>
<ChooseOne>
<Cond condition={isNonInitialPage}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="No audit logs available on this page" />
</TableCell>
</TableRow>
</Cond>
<Cond>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="No audit logs available" />
</TableCell>
</TableRow>
</Cond>
</ChooseOne>
</Cond>
<Cond>
{auditLogs && (
<Timeline
items={auditLogs}
getDate={(log) => new Date(log.time)}
row={(log) => (
<AuditLogRow key={log.id} auditLog={log} />
)}
/>
)}
</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
{count !== undefined && (
<PaginationWidgetBase
totalRecords={count}
pageSize={limit}
onPageChange={onPageChange}
currentPage={page}
/>
)}
<Cond condition={isEmpty}>
<ChooseOne>
<Cond condition={isNonInitialPage}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="No audit logs available on this page" />
</TableCell>
</TableRow>
</Cond>
<Cond>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="No audit logs available" />
</TableCell>
</TableRow>
</Cond>
</ChooseOne>
</Cond>
<Cond>
{auditLogs && (
<Timeline
items={auditLogs}
getDate={(log) => new Date(log.time)}
row={(log) => (
<AuditLogRow key={log.id} auditLog={log} />
)}
/>
)}
</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
</PaginationContainer>
</Cond>
<Cond>

View File

@ -155,10 +155,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
error: usersQuery.error,
menus: { status: statusMenu },
}}
count={usersQuery.totalRecords}
page={usersQuery.currentPage}
limit={usersQuery.limit}
onPageChange={usersQuery.onPageChange}
usersQuery={usersQuery}
/>
<DeleteDialog

View File

@ -13,6 +13,9 @@ import {
getDefaultFilterProps,
} from "components/Filter/storyHelpers";
import { type UsePaginatedQueryResult } from "hooks/usePaginatedQuery";
import { mockSuccessResult } from "components/PaginationWidget/PaginationContainer.mocks";
type FilterProps = ComponentProps<typeof UsersPageView>["filterProps"];
const defaultFilterProps = getDefaultFilterProps<FilterProps>({
@ -29,15 +32,17 @@ const meta: Meta<typeof UsersPageView> = {
title: "pages/UsersPage",
component: UsersPageView,
args: {
page: 1,
limit: 25,
isNonInitialPage: false,
users: [MockUser, MockUser2],
roles: MockAssignableSiteRoles,
count: 2,
canEditUsers: true,
filterProps: defaultFilterProps,
authMethods: MockAuthMethodsPasswordOnly,
usersQuery: {
...mockSuccessResult,
totalRecords: 2,
} as UsePaginatedQueryResult,
},
};
@ -46,32 +51,44 @@ type Story = StoryObj<typeof UsersPageView>;
export const Admin: Story = {};
export const SmallViewport = {
export const SmallViewport: Story = {
parameters: {
chromatic: { viewports: [600] },
},
};
export const Member = {
export const Member: Story = {
args: { canEditUsers: false },
};
export const Empty = {
args: { users: [], count: 0 },
};
export const EmptyPage = {
export const Empty: Story = {
args: {
users: [],
count: 0,
isNonInitialPage: true,
usersQuery: {
...mockSuccessResult,
totalRecords: 0,
} as UsePaginatedQueryResult,
},
};
export const Error = {
export const EmptyPage: Story = {
args: {
users: [],
isNonInitialPage: true,
usersQuery: {
...mockSuccessResult,
totalRecords: 0,
} as UsePaginatedQueryResult,
},
};
export const Error: Story = {
args: {
users: undefined,
count: 0,
usersQuery: {
...mockSuccessResult,
totalRecords: 0,
} as UsePaginatedQueryResult,
filterProps: {
...defaultFilterProps,
error: mockApiError({

View File

@ -5,10 +5,9 @@ import { type GroupsByUserId } from "api/queries/groups";
import { UsersTable } from "./UsersTable/UsersTable";
import { UsersFilter } from "./UsersFilter";
import {
PaginationStatus,
TableToolbar,
} from "components/TableToolbar/TableToolbar";
import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase";
PaginationContainer,
type PaginationResult,
} from "components/PaginationWidget/PaginationContainer";
export interface UsersPageViewProps {
users?: TypesGen.User[];
@ -33,12 +32,7 @@ export interface UsersPageViewProps {
isNonInitialPage: boolean;
actorID: string;
groupsByUserId: GroupsByUserId | undefined;
// Pagination
count?: number;
page: number;
limit: number;
onPageChange: (page: number) => void;
usersQuery: PaginationResult;
}
export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
@ -60,54 +54,35 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
isNonInitialPage,
actorID,
authMethods,
count,
limit,
onPageChange,
page,
groupsByUserId,
usersQuery,
}) => {
return (
<>
<UsersFilter {...filterProps} />
<TableToolbar>
<PaginationStatus
isLoading={Boolean(isLoading)}
showing={users?.length ?? 0}
total={count ?? 0}
label="users"
<PaginationContainer query={usersQuery} paginationUnitLabel="users">
<UsersTable
users={users}
roles={roles}
groupsByUserId={groupsByUserId}
onSuspendUser={onSuspendUser}
onDeleteUser={onDeleteUser}
onListWorkspaces={onListWorkspaces}
onViewActivity={onViewActivity}
onActivateUser={onActivateUser}
onResetUserPassword={onResetUserPassword}
onUpdateUserRoles={onUpdateUserRoles}
isUpdatingUserRoles={isUpdatingUserRoles}
canEditUsers={canEditUsers}
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
canViewActivity={canViewActivity}
isLoading={isLoading}
isNonInitialPage={isNonInitialPage}
actorID={actorID}
authMethods={authMethods}
/>
</TableToolbar>
<UsersTable
users={users}
roles={roles}
groupsByUserId={groupsByUserId}
onSuspendUser={onSuspendUser}
onDeleteUser={onDeleteUser}
onListWorkspaces={onListWorkspaces}
onViewActivity={onViewActivity}
onActivateUser={onActivateUser}
onResetUserPassword={onResetUserPassword}
onUpdateUserRoles={onUpdateUserRoles}
isUpdatingUserRoles={isUpdatingUserRoles}
canEditUsers={canEditUsers}
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
canViewActivity={canViewActivity}
isLoading={isLoading}
isNonInitialPage={isNonInitialPage}
actorID={actorID}
authMethods={authMethods}
/>
{count !== undefined && (
<PaginationWidgetBase
totalRecords={count}
pageSize={limit}
onPageChange={onPageChange}
currentPage={page}
/>
)}
</PaginationContainer>
</>
);
};

View File

@ -116,6 +116,8 @@ const meta: Meta<typeof WorkspacesPageView> = {
canCheckWorkspaces: true,
templates: mockTemplates,
templatesFetchStatus: "success",
count: 13,
page: 1,
},
decorators: [
(Story) => (

View File

@ -11,10 +11,7 @@ import { DormantWorkspaceBanner, Count } from "components/WorkspaceDeletion";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { WorkspacesFilter } from "./filter/filter";
import { hasError, isApiValidationError } from "api/errors";
import {
PaginationStatus,
TableToolbar,
} from "components/TableToolbar/TableToolbar";
import { TableToolbar } from "components/TableToolbar/TableToolbar";
import Box from "@mui/material/Box";
import DeleteOutlined from "@mui/icons-material/DeleteOutlined";
import { WorkspacesButton } from "./WorkspacesButton";
@ -30,6 +27,7 @@ import {
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
import Divider from "@mui/material/Divider";
import LoadingButton from "@mui/lab/LoadingButton";
import { PaginationHeader } from "components/PaginationWidget/PaginationHeader";
export const Language = {
pageTitle: "Workspaces",
@ -186,11 +184,11 @@ export const WorkspacesPageView = ({
</MoreMenu>
</>
) : (
<PaginationStatus
isLoading={!workspaces && !error}
showing={workspaces?.length ?? 0}
total={count ?? 0}
label="workspaces"
<PaginationHeader
paginationUnitLabel="workspaces"
limit={limit}
totalRecords={count}
currentOffsetStart={(page - 1) * limit + 1}
/>
)}
</TableToolbar>
@ -207,12 +205,17 @@ export const WorkspacesPageView = ({
/>
{count !== undefined && (
<PaginationWidgetBase
totalRecords={count}
pageSize={limit}
onPageChange={onPageChange}
currentPage={page}
/>
// Temporary styling stopgap before component is migrated to using
// PaginationContainer (which renders PaginationWidgetBase using CSS
// flexbox gaps)
<div css={{ paddingTop: "16px" }}>
<PaginationWidgetBase
totalRecords={count}
pageSize={limit}
onPageChange={onPageChange}
currentPage={page}
/>
</div>
)}
</Margins>
);

View File

@ -266,6 +266,8 @@ export const waitForLoaderToBeRemoved = async (): Promise<void> => {
);
};
export const renderComponent = (component: React.ReactNode) => {
return tlRender(<ThemeProviders>{component}</ThemeProviders>);
export const renderComponent = (component: React.ReactElement) => {
return tlRender(component, {
wrapper: ({ children }) => <ThemeProviders>{children}</ThemeProviders>,
});
};