mirror of https://github.com/coder/coder.git
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:
parent
d9a169556a
commit
28eca2e53f
|
@ -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,
|
||||
};
|
|
@ -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>,
|
||||
},
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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}–
|
||||
{currentOffsetStart +
|
||||
Math.min(limit - 1, totalRecords - currentOffsetStart)}
|
||||
</strong>{" "}
|
||||
(<strong>{totalRecords.toLocaleString()}</strong>{" "}
|
||||
{paginationUnitLabel} total)
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Skeleton variant="text" width={160} height={16} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 }));
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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] },
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -116,6 +116,8 @@ const meta: Meta<typeof WorkspacesPageView> = {
|
|||
canCheckWorkspaces: true,
|
||||
templates: mockTemplates,
|
||||
templatesFetchStatus: "success",
|
||||
count: 13,
|
||||
page: 1,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>,
|
||||
});
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue