chore: refactor pagination (#4753)

* Extract PageButton

* Fix import

* Extract utils

* Format

* Separate pagination - wip

* Spawn pagination machine - buggy filter

* Make labels optional

* Layout, fix send reset bug

* Format

* Fix refresh data bug

* Remove debugging line

* Fix url updates

setSearchParams overwrites all search params, rather than merging

* Update Audit Page

* Simplify pagination widget

* Fix workspaces story

* Fix Audit story

* Fix pagination story and pagebutton highlight

* Fix pagination tests

* Add to utils tests

* Format

* Add tests
This commit is contained in:
Presley Pizzo
2022-10-26 13:08:22 -04:00
committed by GitHub
parent a0bdb4fca2
commit b0d5e0613e
16 changed files with 548 additions and 461 deletions
@@ -0,0 +1,52 @@
import Button from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles"
interface PageButtonProps {
activePage?: number
page?: number
placeholder?: string
numPages?: number
onPageClick?: (page: number) => void
disabled?: boolean
}
export const PageButton = ({
activePage,
page,
placeholder = "...",
numPages,
onPageClick,
disabled = false,
}: PageButtonProps): JSX.Element => {
const styles = useStyles()
return (
<Button
className={
activePage === page
? `${styles.pageButton} ${styles.activePageButton}`
: styles.pageButton
}
aria-label={`${page === activePage ? "Current Page" : ""} ${
page === numPages ? "Last Page" : ""
} Page${page}`}
name={page === undefined ? undefined : "Page button"}
onClick={() => onPageClick && page && onPageClick(page)}
disabled={disabled}
>
<div>{page ?? placeholder}</div>
</Button>
)
}
const useStyles = makeStyles((theme) => ({
pageButton: {
"&:not(:last-of-type)": {
marginRight: theme.spacing(0.5),
},
},
activePageButton: {
borderColor: `${theme.palette.info.main}`,
backgroundColor: `${theme.palette.info.dark}`,
},
}))
@@ -1,65 +1,53 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import { PaginationWidget, PaginationWidgetProps } from "./PaginationWidget"
import { createPaginationRef } from "./utils"
export default {
title: "components/PaginationWidget",
component: PaginationWidget,
argTypes: {
prevLabel: {
defaultValue: "Previous",
},
nextLabel: {
defaultValue: "Next",
},
paginationRef: {
defaultValue: createPaginationRef({ page: 1, limit: 12 }),
},
numRecords: {
defaultValue: 200,
},
},
}
const Template: Story<PaginationWidgetProps> = (
args: PaginationWidgetProps,
) => <PaginationWidget {...args} />
const defaultProps = {
prevLabel: "Previous",
nextLabel: "Next",
onPrevClick: action("previous"),
onNextClick: action("next"),
onPageClick: action("clicked"),
}
export const UnknownPageNumbers = Template.bind({})
UnknownPageNumbers.args = {
...defaultProps,
numRecords: undefined,
}
export const LessThan8Pages = Template.bind({})
LessThan8Pages.args = {
...defaultProps,
numRecords: 84,
numRecordsPerPage: 12,
activePage: 1,
}
export const MoreThan8Pages = Template.bind({})
MoreThan8Pages.args = {
...defaultProps,
numRecords: 200,
numRecordsPerPage: 12,
activePage: 1,
}
export const MoreThan7PagesWithActivePageCloseToStart = Template.bind({})
MoreThan7PagesWithActivePageCloseToStart.args = {
...defaultProps,
numRecords: 200,
numRecordsPerPage: 12,
activePage: 2,
paginationRef: createPaginationRef({ page: 2, limit: 12 }),
}
export const MoreThan7PagesWithActivePageFarFromBoundaries = Template.bind({})
MoreThan7PagesWithActivePageFarFromBoundaries.args = {
...defaultProps,
numRecords: 200,
numRecordsPerPage: 12,
activePage: 4,
paginationRef: createPaginationRef({ page: 4, limit: 12 }),
}
export const MoreThan7PagesWithActivePageCloseToEnd = Template.bind({})
MoreThan7PagesWithActivePageCloseToEnd.args = {
...defaultProps,
numRecords: 200,
numRecordsPerPage: 12,
activePage: 17,
paginationRef: createPaginationRef({ page: 17, limit: 12 }),
}
@@ -1,6 +1,7 @@
import { screen } from "@testing-library/react"
import { render } from "../../testHelpers/renderHelpers"
import { PaginationWidget } from "./PaginationWidget"
import { createPaginationRef } from "./utils"
describe("PaginatedList", () => {
it("displays an accessible previous and next button", () => {
@@ -8,20 +9,13 @@ describe("PaginatedList", () => {
<PaginationWidget
prevLabel="Previous"
nextLabel="Next"
paginationRef={createPaginationRef({ page: 2, limit: 12 })}
numRecords={200}
numRecordsPerPage={12}
activePage={1}
onPrevClick={() => jest.fn()}
onNextClick={() => jest.fn()}
/>,
)
expect(
screen.getByRole("button", { name: "Previous page" }),
).toBeInTheDocument()
expect(
screen.getByRole("button", { name: "Next page" }),
).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Previous page" })).toBeEnabled()
expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled()
})
it("displays the expected number of pages with one ellipsis tile", () => {
@@ -29,12 +23,8 @@ describe("PaginatedList", () => {
<PaginationWidget
prevLabel="Previous"
nextLabel="Next"
onPrevClick={() => jest.fn()}
onNextClick={() => jest.fn()}
onPageClick={(_) => jest.fn()}
numRecords={200}
numRecordsPerPage={12}
activePage={1}
paginationRef={createPaginationRef({ page: 1, limit: 12 })}
/>,
)
@@ -49,12 +39,8 @@ describe("PaginatedList", () => {
<PaginationWidget
prevLabel="Previous"
nextLabel="Next"
onPrevClick={() => jest.fn()}
onNextClick={() => jest.fn()}
onPageClick={(_) => jest.fn()}
numRecords={200}
numRecordsPerPage={12}
activePage={6}
paginationRef={createPaginationRef({ page: 6, limit: 12 })}
/>,
)
@@ -63,4 +49,26 @@ describe("PaginatedList", () => {
container.querySelectorAll(`button[name="Page button"]`),
).toHaveLength(5)
})
it("disables the previous button on the first page", () => {
render(
<PaginationWidget
numRecords={100}
paginationRef={createPaginationRef({ page: 1, limit: 25 })}
/>,
)
const prevButton = screen.getByLabelText("Previous page")
expect(prevButton).toBeDisabled()
})
it("disables the next button on the last page", () => {
render(
<PaginationWidget
numRecords={100}
paginationRef={createPaginationRef({ page: 4, limit: 25 })}
/>,
)
const nextButton = screen.getByLabelText("Next page")
expect(nextButton).toBeDisabled()
})
})
@@ -3,129 +3,40 @@ import { makeStyles, useTheme } from "@material-ui/core/styles"
import useMediaQuery from "@material-ui/core/useMediaQuery"
import KeyboardArrowLeft from "@material-ui/icons/KeyboardArrowLeft"
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
import { useActor } from "@xstate/react"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import { Maybe } from "components/Conditionals/Maybe"
import { CSSProperties } from "react"
import { PaginationMachineRef } from "xServices/pagination/paginationXService"
import { PageButton } from "./PageButton"
import { buildPagedList } from "./utils"
export type PaginationWidgetProps = {
prevLabel: string
nextLabel: string
onPrevClick: () => void
onNextClick: () => void
onPageClick?: (page: number) => void
numRecordsPerPage?: number
prevLabel?: string
nextLabel?: string
numRecords?: number
activePage?: number
containerStyle?: CSSProperties
}
/**
* Generates a ranged array with an option to step over values.
* Shamelessly stolen from:
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from#sequence_generator_range
*/
const range = (start: number, stop: number, step = 1) =>
Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step)
export const DEFAULT_RECORDS_PER_PAGE = 25
// Number of pages to the left or right of the current page selection.
const PAGE_NEIGHBORS = 1
// Number of pages displayed for cases where there are multiple ellipsis showing. This can be
// thought of as the minimum number of page numbers to display when multiple ellipsis are showing.
const PAGES_TO_DISPLAY = PAGE_NEIGHBORS * 2 + 3
// Total page blocks(page numbers or ellipsis) displayed, including the maximum number of ellipsis (2).
// This gives us maximum number of 7 page blocks to be displayed when the page neighbors value is 1.
const NUM_PAGE_BLOCKS = PAGES_TO_DISPLAY + 2
/**
* Builds a list of pages based on how many pages exist and where the user is in their navigation of those pages.
* List result is used to from the buttons that make up the Pagination Widget
*/
export const buildPagedList = (
numPages: number,
activePage: number,
): (string | number)[] => {
if (numPages > NUM_PAGE_BLOCKS) {
let pages = []
const leftBound = activePage - PAGE_NEIGHBORS
const rightBound = activePage + PAGE_NEIGHBORS
const beforeLastPage = numPages - 1
const startPage = leftBound > 2 ? leftBound : 2
const endPage = rightBound < beforeLastPage ? rightBound : beforeLastPage
pages = range(startPage, endPage)
const singleSpillOffset = PAGES_TO_DISPLAY - pages.length - 1
const hasLeftOverflow = startPage > 2
const hasRightOverflow = endPage < beforeLastPage
const leftOverflowPage = "left"
const rightOverflowPage = "right"
if (hasLeftOverflow && !hasRightOverflow) {
const extraPages = range(startPage - singleSpillOffset, startPage - 1)
pages = [leftOverflowPage, ...extraPages, ...pages]
} else if (!hasLeftOverflow && hasRightOverflow) {
const extraPages = range(endPage + 1, endPage + singleSpillOffset)
pages = [...pages, ...extraPages, rightOverflowPage]
} else if (hasLeftOverflow && hasRightOverflow) {
pages = [leftOverflowPage, ...pages, rightOverflowPage]
}
return [1, ...pages, numPages]
}
return range(1, numPages)
}
interface PageButtonProps {
activePage: number
page: number
numPages: number
onPageClick?: (page: number) => void
}
const PageButton = ({
activePage,
page,
numPages,
onPageClick,
}: PageButtonProps): JSX.Element => {
const styles = useStyles()
return (
<Button
className={
activePage === page
? `${styles.pageButton} ${styles.activePageButton}`
: styles.pageButton
}
aria-label={`${page === activePage ? "Current Page" : ""} ${
page === numPages ? "Last Page" : ""
} Page${page}`}
name="Page button"
onClick={() => onPageClick && onPageClick(page)}
>
<div>{page}</div>
</Button>
)
paginationRef: PaginationMachineRef
}
export const PaginationWidget = ({
prevLabel,
nextLabel,
onPrevClick,
onNextClick,
onPageClick,
prevLabel = "",
nextLabel = "",
numRecords,
numRecordsPerPage = DEFAULT_RECORDS_PER_PAGE,
activePage = 1,
containerStyle,
paginationRef,
}: PaginationWidgetProps): JSX.Element | null => {
const numPages = numRecords ? Math.ceil(numRecords / numRecordsPerPage) : 0
const firstPageActive = activePage === 1 && numPages !== 0
const lastPageActive = activePage === numPages && numPages !== 0
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down("sm"))
const styles = useStyles()
const [paginationState, send] = useActor(paginationRef)
const currentPage = paginationState.context.page
const numRecordsPerPage = paginationState.context.limit
const numPages = numRecords ? Math.ceil(numRecords / numRecordsPerPage) : 0
const firstPageActive = currentPage === 1 && numPages !== 0
const lastPageActive = currentPage === numPages && numPages !== 0
// No need to display any pagination if we know the number of pages is 1 or 0
if (numPages <= 1 || numRecords === 0) {
@@ -138,7 +49,7 @@ export const PaginationWidget = ({
className={styles.prevLabelStyles}
aria-label="Previous page"
disabled={firstPageActive}
onClick={onPrevClick}
onClick={() => send({ type: "PREVIOUS_PAGE" })}
>
<KeyboardArrowLeft />
<div>{prevLabel}</div>
@@ -147,28 +58,27 @@ export const PaginationWidget = ({
<ChooseOne>
<Cond condition={isMobile}>
<PageButton
activePage={activePage}
page={activePage}
activePage={currentPage}
page={currentPage}
numPages={numPages}
/>
</Cond>
<Cond>
{buildPagedList(numPages, activePage).map((page) =>
{buildPagedList(numPages, currentPage).map((page) =>
typeof page !== "number" ? (
<Button
className={styles.pageButton}
<PageButton
key={`Page${page}`}
activePage={currentPage}
placeholder="..."
disabled
>
<div>...</div>
</Button>
/>
) : (
<PageButton
key={`Page${page}`}
activePage={activePage}
activePage={currentPage}
page={page}
numPages={numPages}
onPageClick={onPageClick}
onPageClick={() => send({ type: "GO_TO_PAGE", page })}
/>
),
)}
@@ -178,7 +88,7 @@ export const PaginationWidget = ({
<Button
aria-label="Next page"
disabled={lastPageActive}
onClick={onNextClick}
onClick={() => send({ type: "NEXT_PAGE" })}
>
<div>{nextLabel}</div>
<KeyboardArrowRight />
@@ -199,15 +109,4 @@ const useStyles = makeStyles((theme) => ({
prevLabelStyles: {
marginRight: `${theme.spacing(0.5)}px`,
},
pageButton: {
"&:not(:last-of-type)": {
marginRight: theme.spacing(0.5),
},
},
activePageButton: {
borderColor: `${theme.palette.info.main}`,
backgroundColor: `${theme.palette.info.dark}`,
},
}))
@@ -1,4 +1,4 @@
import { buildPagedList } from "./PaginationWidget"
import { buildPagedList, getOffset } from "./utils"
describe("unit/PaginationWidget", () => {
describe("buildPagedList", () => {
@@ -27,3 +27,16 @@ describe("unit/PaginationWidget", () => {
)
})
})
describe("getOffset", () => {
it("returns 0 on page 1", () => {
const page = 1
const limit = 10
expect(getOffset(page, limit)).toEqual(0)
})
it("returns the limit on page 2", () => {
const page = 2
const limit = 10
expect(getOffset(page, limit)).toEqual(limit)
})
})
@@ -0,0 +1,104 @@
import {
PaginationContext,
paginationMachine,
PaginationMachineRef,
} from "xServices/pagination/paginationXService"
import { spawn } from "xstate"
/**
* Generates a ranged array with an option to step over values.
* Shamelessly stolen from:
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from#sequence_generator_range
*/
const range = (start: number, stop: number, step = 1) =>
Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step)
export const DEFAULT_RECORDS_PER_PAGE = 25
// Number of pages to the left or right of the current page selection.
const PAGE_NEIGHBORS = 1
// Number of pages displayed for cases where there are multiple ellipsis showing. This can be
// thought of as the minimum number of page numbers to display when multiple ellipsis are showing.
const PAGES_TO_DISPLAY = PAGE_NEIGHBORS * 2 + 3
// Total page blocks(page numbers or ellipsis) displayed, including the maximum number of ellipsis (2).
// This gives us maximum number of 7 page blocks to be displayed when the page neighbors value is 1.
const NUM_PAGE_BLOCKS = PAGES_TO_DISPLAY + 2
/**
* Builds a list of pages based on how many pages exist and where the user is in their navigation of those pages.
* List result is used to from the buttons that make up the Pagination Widget
*/
export const buildPagedList = (
numPages: number,
activePage: number,
): (string | number)[] => {
if (numPages > NUM_PAGE_BLOCKS) {
let pages = []
const leftBound = activePage - PAGE_NEIGHBORS
const rightBound = activePage + PAGE_NEIGHBORS
const beforeLastPage = numPages - 1
const startPage = leftBound > 2 ? leftBound : 2
const endPage = rightBound < beforeLastPage ? rightBound : beforeLastPage
pages = range(startPage, endPage)
const singleSpillOffset = PAGES_TO_DISPLAY - pages.length - 1
const hasLeftOverflow = startPage > 2
const hasRightOverflow = endPage < beforeLastPage
const leftOverflowPage = "left"
const rightOverflowPage = "right"
if (hasLeftOverflow && !hasRightOverflow) {
const extraPages = range(startPage - singleSpillOffset, startPage - 1)
pages = [leftOverflowPage, ...extraPages, ...pages]
} else if (!hasLeftOverflow && hasRightOverflow) {
const extraPages = range(endPage + 1, endPage + singleSpillOffset)
pages = [...pages, ...extraPages, rightOverflowPage]
} else if (hasLeftOverflow && hasRightOverflow) {
pages = [leftOverflowPage, ...pages, rightOverflowPage]
}
return [1, ...pages, numPages]
}
return range(1, numPages)
}
const getInitialPage = (page: string | null): number =>
page ? Number(page) : 1
// pages count from 1
export const getOffset = (page: number, limit: number): number =>
(page - 1) * limit
interface PaginationData {
offset: number
limit: number
}
export const getPaginationData = (
ref: PaginationMachineRef,
): PaginationData => {
const snapshot = ref.getSnapshot()
if (snapshot) {
const { page, limit } = snapshot.context
const offset = getOffset(page, limit)
return { offset, limit }
} else {
throw new Error("No pagination data")
}
}
export const getPaginationContext = (
searchParams: URLSearchParams,
limit: number = DEFAULT_RECORDS_PER_PAGE,
): PaginationContext => ({
page: getInitialPage(searchParams.get("page")),
limit,
})
// for storybook
export const createPaginationRef = (
context: PaginationContext,
): PaginationMachineRef => {
return spawn(paginationMachine.withContext(context))
}
@@ -65,5 +65,29 @@ describe("AuditPage", () => {
expect(getAuditLogsSpy).toBeCalledWith({ limit: 25, offset: 0, q: query })
})
it("resets page to 1 when filter is changed", async () => {
const getAuditLogsSpy = jest
.spyOn(API, "getAuditLogs")
.mockResolvedValue({ audit_logs: [MockAuditLog] })
history.push(`/audit?page=2`)
render(<AuditPage />)
await waitForLoaderToBeRemoved()
getAuditLogsSpy.mockReset()
const filterField = screen.getByLabelText("Filter")
const query = "resource_type:workspace action:create"
await userEvent.type(filterField, query)
await waitFor(() =>
expect(getAuditLogsSpy).toBeCalledWith({
limit: 25,
offset: 0,
q: query,
}),
)
})
})
})
+12 -28
View File
@@ -1,34 +1,29 @@
import { useMachine } from "@xstate/react"
import { getPaginationContext } from "components/PaginationWidget/utils"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useNavigate, useSearchParams } from "react-router-dom"
import { useFilter } from "util/filters"
import { useSearchParams } from "react-router-dom"
import { pageTitle } from "util/page"
import { auditMachine } from "xServices/audit/auditXService"
import { PaginationMachineRef } from "xServices/pagination/paginationXService"
import { AuditPageView } from "./AuditPageView"
const AuditPage: FC = () => {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const currentPage = searchParams.get("page")
? Number(searchParams.get("page"))
: 1
const { filter, setFilter } = useFilter("")
const [searchParams, setSearchParams] = useSearchParams()
const filter = searchParams.get("filter") ?? ""
const [auditState, auditSend] = useMachine(auditMachine, {
context: {
page: currentPage,
limit: 25,
filter,
paginationContext: getPaginationContext(searchParams),
},
actions: {
onPageChange: ({ page }) => {
navigate({
search: `?page=${page}`,
})
},
updateURL: (context, event) =>
setSearchParams({ page: event.page, filter: context.filter }),
},
})
const { auditLogs, count, page, limit } = auditState.context
const { auditLogs, count } = auditState.context
const paginationRef = auditState.context.paginationRef as PaginationMachineRef
return (
<>
@@ -39,21 +34,10 @@ const AuditPage: FC = () => {
filter={filter}
auditLogs={auditLogs}
count={count}
page={page}
limit={limit}
onNext={() => {
auditSend("NEXT")
}}
onPrevious={() => {
auditSend("PREVIOUS")
}}
onGoToPage={(page) => {
auditSend("GO_TO_PAGE", { page })
}}
onFilter={(filter) => {
setFilter(filter)
auditSend("FILTER", { filter })
}}
paginationRef={paginationRef}
/>
</>
)
@@ -1,10 +1,22 @@
import { ComponentMeta, Story } from "@storybook/react"
import { createPaginationRef } from "components/PaginationWidget/utils"
import { MockAuditLog, MockAuditLog2 } from "testHelpers/entities"
import { AuditPageView, AuditPageViewProps } from "./AuditPageView"
export default {
title: "pages/AuditPageView",
component: AuditPageView,
argTypes: {
auditLogs: {
defaultValue: [MockAuditLog, MockAuditLog2],
},
count: {
defaultValue: 1000,
},
paginationRef: {
defaultValue: createPaginationRef({ page: 1, limit: 25 }),
},
},
} as ComponentMeta<typeof AuditPageView>
const Template: Story<AuditPageViewProps> = (args) => (
@@ -12,20 +24,8 @@ const Template: Story<AuditPageViewProps> = (args) => (
)
export const AuditPage = Template.bind({})
AuditPage.args = {
auditLogs: [MockAuditLog, MockAuditLog2],
count: 1000,
page: 1,
limit: 25,
}
export const AuditPageSmallViewport = Template.bind({})
AuditPageSmallViewport.args = {
auditLogs: [MockAuditLog, MockAuditLog2],
count: 1000,
page: 1,
limit: 25,
}
AuditPageSmallViewport.parameters = {
chromatic: { viewports: [600] },
}
+4 -22
View File
@@ -19,6 +19,7 @@ import { Stack } from "components/Stack/Stack"
import { TableLoader } from "components/TableLoader/TableLoader"
import { AuditHelpTooltip } from "components/Tooltips"
import { FC } from "react"
import { PaginationMachineRef } from "xServices/pagination/paginationXService"
export const Language = {
title: "Audit",
@@ -39,25 +40,17 @@ const presetFilters = [
export interface AuditPageViewProps {
auditLogs?: AuditLog[]
count?: number
page: number
limit: number
filter: string
onFilter: (filter: string) => void
onNext: () => void
onPrevious: () => void
onGoToPage: (page: number) => void
paginationRef: PaginationMachineRef
}
export const AuditPageView: FC<AuditPageViewProps> = ({
auditLogs,
count,
page,
limit,
filter,
onFilter,
onNext,
onPrevious,
onGoToPage,
paginationRef,
}) => {
const isLoading = auditLogs === undefined || count === undefined
const isEmpty = !isLoading && auditLogs.length === 0
@@ -106,18 +99,7 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
</Table>
</TableContainer>
{count && count > limit ? (
<PaginationWidget
prevLabel=""
nextLabel=""
onPrevClick={onPrevious}
onNextClick={onNext}
onPageClick={onGoToPage}
numRecords={count}
activePage={page}
numRecordsPerPage={limit}
/>
) : null}
<PaginationWidget numRecords={count} paginationRef={paginationRef} />
</Margins>
)
}
@@ -1,43 +1,33 @@
import { useMachine } from "@xstate/react"
import { DEFAULT_RECORDS_PER_PAGE } from "components/PaginationWidget/PaginationWidget"
import { getPaginationContext } from "components/PaginationWidget/utils"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useNavigate, useSearchParams } from "react-router-dom"
import { useSearchParams } from "react-router-dom"
import { workspaceFilterQuery } from "util/filters"
import { pageTitle } from "util/page"
import { PaginationMachineRef } from "xServices/pagination/paginationXService"
import { workspacesMachine } from "xServices/workspaces/workspacesXService"
import { WorkspacesPageView } from "./WorkspacesPageView"
const WorkspacesPage: FC = () => {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const filter = searchParams.get("filter") ?? workspaceFilterQuery.me
const currentPage = searchParams.get("page")
? Number(searchParams.get("page"))
: 1
const [workspacesState, send] = useMachine(workspacesMachine, {
context: {
page: currentPage,
limit: DEFAULT_RECORDS_PER_PAGE,
filter,
paginationContext: getPaginationContext(searchParams),
},
actions: {
onPageChange: ({ page }) => {
navigate({
search: `?page=${page}`,
})
},
// Filter updates always cause page updates (to page 1), so only UPDATE_PAGE triggers updateURL
updateURL: (context, event) =>
setSearchParams({ page: event.page, filter: context.filter }),
},
})
const {
workspaceRefs,
count,
page,
limit,
getWorkspacesError,
getCountError,
} = workspacesState.context
const { workspaceRefs, count, getWorkspacesError, getCountError } =
workspacesState.context
const paginationRef = workspacesState.context
.paginationRef as PaginationMachineRef
return (
<>
@@ -52,24 +42,13 @@ const WorkspacesPage: FC = () => {
count={count}
getWorkspacesError={getWorkspacesError}
getCountError={getCountError}
page={page}
limit={limit}
onNext={() => {
send("NEXT")
}}
onPrevious={() => {
send("PREVIOUS")
}}
onGoToPage={(page) => {
send("GO_TO_PAGE", { page })
}}
onFilter={(query) => {
setSearchParams({ filter: query })
send({
type: "UPDATE_FILTER",
query,
})
}}
paginationRef={paginationRef}
/>
</>
)
@@ -1,4 +1,5 @@
import { ComponentMeta, Story } from "@storybook/react"
import { createPaginationRef } from "components/PaginationWidget/utils"
import dayjs from "dayjs"
import { spawn } from "xstate"
import {
@@ -87,6 +88,9 @@ export default {
title: "pages/WorkspacesPageView",
component: WorkspacesPageView,
argTypes: {
paginationRef: {
defaultValue: createPaginationRef({ page: 1, limit: 25 }),
},
workspaceRefs: {
options: [
...Object.keys(workspaces),
@@ -4,6 +4,7 @@ import { Maybe } from "components/Conditionals/Maybe"
import { PaginationWidget } from "components/PaginationWidget/PaginationWidget"
import { FC } from "react"
import { Link as RouterLink } from "react-router-dom"
import { PaginationMachineRef } from "xServices/pagination/paginationXService"
import { Margins } from "../../components/Margins/Margins"
import {
PageHeader,
@@ -32,13 +33,9 @@ export interface WorkspacesPageViewProps {
count?: number
getWorkspacesError: Error | unknown
getCountError: Error | unknown
page: number
limit: number
filter?: string
onFilter: (query: string) => void
onNext: () => void
onPrevious: () => void
onGoToPage: (page: number) => void
paginationRef: PaginationMachineRef
}
export const WorkspacesPageView: FC<
@@ -49,13 +46,9 @@ export const WorkspacesPageView: FC<
count,
getWorkspacesError,
getCountError,
page,
limit,
filter,
onFilter,
onNext,
onPrevious,
onGoToPage,
paginationRef,
}) => {
const presetFilters = [
{ query: workspaceFilterQuery.me, name: Language.yourWorkspacesButton },
@@ -114,16 +107,7 @@ export const WorkspacesPageView: FC<
filter={filter}
/>
<PaginationWidget
prevLabel=""
nextLabel=""
onPrevClick={onPrevious}
onNextClick={onNext}
onPageClick={onGoToPage}
numRecords={count}
activePage={page}
numRecordsPerPage={limit}
/>
<PaginationWidget numRecords={count} paginationRef={paginationRef} />
</Margins>
)
}
+47 -47
View File
@@ -2,14 +2,22 @@ import { getAuditLogs, getAuditLogsCount } from "api/api"
import { getErrorMessage } from "api/errors"
import { AuditLog } from "api/typesGenerated"
import { displayError } from "components/GlobalSnackbar/utils"
import { assign, createMachine } from "xstate"
import { getPaginationData } from "components/PaginationWidget/utils"
import {
PaginationContext,
PaginationMachineRef,
paginationMachine,
} from "xServices/pagination/paginationXService"
import { assign, createMachine, spawn, send } from "xstate"
const auditPaginationId = "auditPagination"
interface AuditContext {
auditLogs?: AuditLog[]
count?: number
page: number
limit: number
filter: string
paginationContext: PaginationContext
paginationRef?: PaginationMachineRef
}
export const auditMachine = createMachine(
@@ -29,22 +37,20 @@ export const auditMachine = createMachine(
},
events: {} as
| {
type: "NEXT"
}
| {
type: "PREVIOUS"
}
| {
type: "GO_TO_PAGE"
page: number
type: "UPDATE_PAGE"
page: string
}
| {
type: "FILTER"
filter: string
},
},
initial: "loading",
initial: "startPagination",
states: {
startPagination: {
entry: "assignPaginationRef",
always: "loading",
},
loading: {
// Right now, XState doesn't a good job with state + context typing so
// this forces the AuditPageView to showing the loading state when the
@@ -65,21 +71,12 @@ export const auditMachine = createMachine(
},
success: {
on: {
NEXT: {
actions: ["assignNextPage", "onPageChange"],
target: "loading",
},
PREVIOUS: {
actions: ["assignPreviousPage", "onPageChange"],
target: "loading",
},
GO_TO_PAGE: {
actions: ["assignPage", "onPageChange"],
UPDATE_PAGE: {
actions: ["updateURL"],
target: "loading",
},
FILTER: {
actions: ["assignFilter"],
target: "loading",
actions: ["assignFilter", "sendResetPage"],
},
},
},
@@ -97,14 +94,12 @@ export const auditMachine = createMachine(
auditLogs: (_, event) => event.data.auditLogs,
count: (_, event) => event.data.count,
}),
assignNextPage: assign({
page: ({ page }) => page + 1,
}),
assignPreviousPage: assign({
page: ({ page }) => page - 1,
}),
assignPage: assign({
page: (_, { page }) => page,
assignPaginationRef: assign({
paginationRef: (context) =>
spawn(
paginationMachine.withContext(context.paginationContext),
auditPaginationId,
),
}),
assignFilter: assign({
filter: (_, { filter }) => filter,
@@ -116,24 +111,29 @@ export const auditMachine = createMachine(
)
displayError(message)
},
sendResetPage: send({ type: "RESET_PAGE" }, { to: auditPaginationId }),
},
services: {
loadAuditLogsAndCount: async ({ page, limit, filter }, _) => {
const [auditLogs, count] = await Promise.all([
getAuditLogs({
// The page in the API starts at 0
offset: (page - 1) * limit,
limit,
q: filter,
}).then((data) => data.audit_logs),
getAuditLogsCount({
q: filter,
}).then((data) => data.count),
])
loadAuditLogsAndCount: async (context) => {
if (context.paginationRef) {
const { offset, limit } = getPaginationData(context.paginationRef)
const [auditLogs, count] = await Promise.all([
getAuditLogs({
offset,
limit,
q: context.filter,
}).then((data) => data.audit_logs),
getAuditLogsCount({
q: context.filter,
}).then((data) => data.count),
])
return {
auditLogs,
count,
return {
auditLogs,
count,
}
} else {
throw new Error("Cannot get audit logs without pagination data")
}
},
},
@@ -0,0 +1,66 @@
import { ActorRefFrom, createMachine, sendParent, assign } from "xstate"
export interface PaginationContext {
page: number
limit: number
}
export type PaginationEvent =
| { type: "NEXT_PAGE" }
| { type: "PREVIOUS_PAGE" }
| { type: "GO_TO_PAGE"; page: number }
| { type: "RESET_PAGE" }
export type PaginationMachineRef = ActorRefFrom<typeof paginationMachine>
export const paginationMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QAcCGUCWA7VAXDA9lgLKoDGAFtmAMQByAogBoAqA+gAoCCA4gwNoAGALqIUBWBnxExIAB6IATADZBAOgAcGgCwBWDYMEHBAdm2KANCACeiEwEZ7axboCcbkw8-3XGgL5+VmiYONIk5FRYtBwASgwAagCSAPIAqgDKnLwCIrLIElKEWLIKCAC09hrKarq6AMwarsraGormuiYaVrYIDk4u7q7e3r4BQejYeEWklNQ0PMlsLIvcfEKiSCD5kmElduq69vrKyiaCjqfu3YgauupatYp1eia+o4FbE6HTEXNx6Qx2KschtxDsintyvZqlVzmdGs0tIpbtcENplIoanV7M8jrpFCZntoAh8sAQIHA8l8pkQZpEwGoAE5gVAQHpgwoyTalZSuNTuV6CFzabSCFqdVFmdTPMU+dHnZrKMafEI08KzKJ5Aq7blKJwC1xC3QisUaCU2RDKQ5qGXaOWqaHokl+IA */
createMachine(
{
tsTypes: {} as import("./paginationXService.typegen").Typegen0,
schema: {
context: {} as PaginationContext,
events: {} as PaginationEvent,
},
predictableActionArguments: true,
id: "paginationMachine",
initial: "ready",
on: {
NEXT_PAGE: {
actions: ["assignNextPage", "sendUpdatePage"],
},
PREVIOUS_PAGE: {
actions: ["assignPreviousPage", "sendUpdatePage"],
},
GO_TO_PAGE: {
actions: ["assignPage", "sendUpdatePage"],
},
RESET_PAGE: {
actions: ["resetPage", "sendUpdatePage"],
},
},
states: {
ready: {},
},
},
{
actions: {
sendUpdatePage: sendParent((context) => ({
type: "UPDATE_PAGE",
page: context.page.toString(),
})),
assignNextPage: assign({
page: (context) => context.page + 1,
}),
assignPreviousPage: assign({
page: (context) => context.page - 1,
}),
assignPage: assign({
page: (_, event) => event.page,
}),
resetPage: assign({
page: (_) => 1,
}),
},
},
)
@@ -1,4 +1,10 @@
import { ActorRefFrom, assign, createMachine, spawn } from "xstate"
import { getPaginationData } from "components/PaginationWidget/utils"
import {
PaginationContext,
paginationMachine,
PaginationMachineRef,
} from "xServices/pagination/paginationXService"
import { ActorRefFrom, assign, createMachine, spawn, send } from "xstate"
import * as API from "../../api/api"
import { getErrorMessage } from "../../api/errors"
import * as TypesGen from "../../api/typesGenerated"
@@ -9,6 +15,8 @@ import {
} from "../../components/GlobalSnackbar/utils"
import { queryToFilter } from "../../util/filters"
export const workspacePaginationId = "workspacePagination"
/**
* Workspace item machine
*
@@ -204,23 +212,21 @@ export type WorkspaceItemMachineRef = ActorRefFrom<typeof workspaceItemMachine>
interface WorkspacesContext {
workspaceRefs?: WorkspaceItemMachineRef[]
paginationRef?: PaginationMachineRef
filter: string
count?: number
getWorkspacesError?: Error | unknown
getCountError?: Error | unknown
page: number
count?: number
limit: number
paginationContext: PaginationContext
}
type WorkspacesEvent =
| { type: "UPDATE_FILTER"; query?: string }
| { type: "UPDATE_PAGE"; page: string }
| { type: "UPDATE_VERSION"; workspaceId: string }
| { type: "NEXT" }
| { type: "PREVIOUS" }
| { type: "GO_TO_PAGE"; page: number }
| { type: "UPDATE_FILTER"; query?: string }
export const workspacesMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QHcD2AnA1rADgQwGM4BlAFz1LADpk8BLUgFVQCUwAzdOACwHUNs+IrADEAD1jlKVPO0roAFAFYADGoCUItFlyESU6rQbM2nHvx1C4AbRUBdRKBypYDOqgB2jkGMQBGAGYANgCqFQAmAE4-cIAOOPDwoIB2ZIAaEABPRHCVKljAv2S-EMiClSUAFliAXxqM7UE9WDIKanYwUgJuOg8oKgJUAFcPUioYUlJeqABhYdGRCE9qXoA3VExqCYsm4TmR0lsHJBBnVynPb18ESsq8uOLk2KCVSqVY4qUM7IQAyqoApElOFKskgpEUn5avUQI1dMJWtIOl0en0BvMxhMpn19gswOh0BgqDgADYUdgYAC2406O3hcFxh3s3jObkuJ2ufyUVEiAXi4QCySUAWBARF3xyaiowuqZShQSCiUidQaAnpLQMVGR3WmNDVVlgVCGOAgFGmdKsplESw8Kw8602RpNbQteitRxZLjZXg5iFitwByTKARUQVulQCiQlCBeeX9sWifihhXBKth+uaiPanR1aLhBppk3NGeEi2WVDWGy2tJLNmZJ1ZFx9oGub25f0jyQqSiUQvi0aUiqoiXCfnecT8kRUwTT+czmu1qP6c+EhexUFdpZtdod1dIm5sfmOTi9TauiFukWlCb5097-tD0cqQ57dw+8cCz9ntY1bS1OaXPVLGaNdi2A0t8UJdBiTJUgKXQalth-D0G1Pdxmx8fximHF5O1uVJKgFAJoxCZIqChPlIhBUMngib9wP0P9F2mMtbSoSQ-xXRikQA6YUJPc50PPBBAhSYcQWBJJcmBfsshyIpxNHLsCiUIFIyCejdm4sARAAcQAUUYAB9XgAHkWAAaWIAAFABBGZ9OIfjTjQ9kW0QABaQiwmKadkhDQjgmSSpow8gIx3Ivkg17KIwSUPxNPVLMRAAVWsgARWzGH0oyADEAEkABlspYZzGyE30ECePwAVicLkiiJNKjHcJQvC-5Xg+Io+XHYFEoNZK0sy7KjIANX0lhiHy0yADkytcjDrj8NQyNHBUilUYplsiNrvJFJJVEHZqew0mEuN-SgRBm-SAA1GHmwS3MwkSgm5XsFQO4E-FBQI2u+sJqgaiFAeKV7+vnNoRGslh9NG6aUqc+sBO9YTBX+cFR1HQdIgjI6-o6wGojDD5QaUcGEQMPTTKMxhqbsgyHpRyr3iCKhgtE0E7gFZ58YBj4iZBkoybTDxUAgOBvHOrMaHoJhWA4LhYD4H9PUexb-DFdHe26p44hFPxo1EnkgihfzBV5QI+rOn9peYtFBgOUCcQxVWmfc35-nCVTYh9iFwpBEFo0BAEp3iCJRzUEVKnJ7T-xRXUHdGKht1ds9KpCVnXj+V5ihBD7ozWtnEnlPluZ7AIY4u7N4-tl3ULV4SPMCNm7iTZbgg+d7QqqKgXlBYIIxUSIcd5Svbd4vMfydU11wPK1U4q926vCHkdeFKiXjHJ9qjZwcffUwEVGW4XVQYqu49zZcp6xMCtPgeu3eev5pVSSdEjFRUShKbfuSFIJ339hCL2p1T533HjXK+Z9k7LAXk9a4xRPZ3F8tzMUEJoz+TeikXkipQx7wrtbM+4DL5ATvrA9WCAm6hEInEFQqQFSCjqv6IOg5d7-zeIEP4pQx4LgnlAMhjcTYtyPkmac-8hRglCt9UIfcGrdg+LQj43C2j8Mqk3IeQi26iM7hIuSFC7j-ECD7LOdVBRDzqHUIAA */
/** @xstate-layout N4IgpgJg5mDOIC5QHcD2AnA1rADgQwGM4BlAFz1LADoDUBXAO1KplNIEsGoBhepgYgioG1TgDdUmaqwDqGbPiKxejUgG0ADAF1EoHKljsOw3SAAeiAIyWNVAMwA2AJwAOACyWnAJjsuvGty8AGhAAT0QfWxcAVm9ohwcXAHYkp0sXFwBfTJC0LFxCEnJKGj5mVg4uFQEwdHQMKhwAGwoAMwwAWxYwUjl8xThq9W1TfUNjBlMLBDsnaKovaMtfDQ00jLckhxDwhEiqGO9-OxPZk69s3PkCpTIKalpVfgBVAAUAEQBBABUAUQB9ABiAEkADJ-ABKmh0SBAYyM7BMsOmbm8VA00Q0KRcli2di86TcOwiDioDk8DjcMS8Lm8dg0LgclxAeQUhVgdxKrJucCosHI6EqUFeeCgnAoiIY-GhowMCKRoGmAFocU57J4vG58VSMqliQglR4FmsvF5sX5XMloszuQMOcVqLb2d02JwoH02UpBMJRAwJFIXR6ebAZbD4RMpoglfF5gSGWt8ZYdZZ9ekXFQktEXI58dn3B4LjkWdc7ZzHSXnRU3UG7d6RFRxJJpD0a+y1JYYXo5RHkVHGZYqE43AEYgnnNE7PqaUl0dFTU4NA5Ui41m4bRXbg6qE6lC6ha2vbV6uhGi1SO10F1ZBu4KGu+NJZGDdFAmTacscTSnLn9RkFvS-CTKlNmia0ix3Ip7m3G9YCoOgcAgCUuAPMAITAVpYDrX1-WoeDEMoFC0Iwu84W7R9ewNelSQ0JNyQ0Al4hsE59XOKg3GHId0gcHwkgyMCrn6dky2gwTd2QPAjG+VAiPQOAAAsUMwsx+SgvBWkodAAApMVWABKfgIPtKDDO3CTSCkmT5MUkjw3IxUoznGdVksHw7FA1xZicFi1QcRccVpAleIcF911EyCuRgl4Ph+AFXk+ABxX4bLIhVzAc78DjsawHDc7wdW2MIo2WNVeNNTMaTNLLF1Cz1wrAKKvj+f4ADVfghYhgQAeQAOWSh9UumWYM2cWjVixdxJ0Kg1NXmXzEhcjF3Bo7IiwYVAIDgUxDOEx4mAbCAmjAWV+smCj-HTLK50xZxFg0WZgimxwSrcSkkheuY3GiJJCwE2qjJKXbyh6IUhmO+VTvshBuIORJKQJHx4esKcfOOfwEi-OxUhq4MdrKMGe0hpMYcZQIXMTAkXP1JVoZWNyGSTQkMX44swv+8tWb5AUhRFMUGAlVLbIGvtGSoSxYj8LNPretIqaTWwvG-JwcvYrxfJfH6Wb+4STKrZCYPxuy0oNWlYxiNyTi1fx0inUlYnJJwHY8dwJ3ibHSy3Ey8KQ90byI+AwxSiGjaVJJrCoJYGUCF9s3xFjUTJD7VdcdItjFt2hI9mDTMk6T0Nk2AFP1gOTqfJU3IHb7UmxSI3qxFi7AWKuFbSBImacdPN2Mov73B0uhwbmkXYt-EaJcKn5wWRXuN4r6XNAju6oNoWDQdgdB-NuxLdHqmsvTBXZm4lyMgpJkVqAA */
createMachine(
{
tsTypes: {} as import("./workspacesXService.typegen").Typegen1,
@@ -245,113 +251,102 @@ export const workspacesMachine =
predictableActionArguments: true,
id: "workspacesState",
on: {
UPDATE_FILTER: {
target: ".fetching",
actions: ["assignFilter", "resetPage"],
},
UPDATE_VERSION: {
actions: "triggerUpdateVersion",
},
NEXT: {
target: ".fetching",
actions: ["assignNextPage", "onPageChange"],
},
PREVIOUS: {
target: ".fetching",
actions: ["assignPreviousPage", "onPageChange"],
},
GO_TO_PAGE: {
target: ".fetching",
actions: ["assignPage", "onPageChange"],
},
},
initial: "fetching",
type: "parallel",
states: {
waitToRefreshWorkspaces: {
after: {
"5000": {
target: "#workspacesState.fetching",
actions: [],
internal: false,
count: {
initial: "gettingCount",
states: {
idle: {},
gettingCount: {
entry: "clearGetCountError",
invoke: {
src: "getWorkspacesCount",
id: "getWorkspacesCount",
onDone: [
{
target: "idle",
actions: "assignCount",
},
],
onError: [
{
target: "idle",
actions: "assignGetCountError",
},
],
},
},
},
on: {
UPDATE_FILTER: {
target: ".gettingCount",
actions: ["assignFilter", "sendResetPage"],
},
},
},
fetching: {
type: "parallel",
workspaces: {
initial: "startingPagination",
states: {
count: {
initial: "gettingCount",
states: {
gettingCount: {
entry: "clearGetCountError",
invoke: {
src: "getWorkspacesCount",
id: "getWorkspacesCount",
onDone: [
{
target: "done",
actions: "assignCount",
},
],
onError: [
{
target: "done",
actions: "assignGetCountError",
},
],
},
},
done: {
type: "final",
},
startingPagination: {
entry: "assignPaginationRef",
always: {
target: "gettingWorkspaces",
},
},
workspaces: {
initial: "gettingWorkspaces",
states: {
updatingWorkspaceRefs: {
invoke: {
src: "updateWorkspaceRefs",
id: "updateWorkspaceRefs",
onDone: [
{
target: "done",
actions: "assignUpdatedWorkspaceRefs",
},
],
gettingWorkspaces: {
entry: "clearGetWorkspacesError",
invoke: {
src: "getWorkspaces",
id: "getWorkspaces",
onDone: [
{
target: "waitToRefreshWorkspaces",
cond: "isEmpty",
actions: "assignWorkspaceRefs",
},
},
gettingWorkspaces: {
entry: "clearGetWorkspacesError",
invoke: {
src: "getWorkspaces",
id: "getWorkspaces",
onDone: [
{
target: "done",
cond: "isEmpty",
actions: "assignWorkspaceRefs",
},
{
target: "updatingWorkspaceRefs",
},
],
onError: [
{
target: "done",
actions: "assignGetWorkspacesError",
},
],
{
target: "updatingWorkspaceRefs",
},
},
done: {
type: "final",
],
onError: [
{
target: "waitToRefreshWorkspaces",
actions: "assignGetWorkspacesError",
},
],
},
},
updatingWorkspaceRefs: {
invoke: {
src: "updateWorkspaceRefs",
id: "updateWorkspaceRefs",
onDone: [
{
target: "waitToRefreshWorkspaces",
actions: "assignUpdatedWorkspaceRefs",
},
],
},
},
waitToRefreshWorkspaces: {
after: {
"5000": {
target: "#workspacesState.workspaces.gettingWorkspaces",
actions: [],
internal: false,
},
},
},
},
onDone: {
target: "waitToRefreshWorkspaces",
on: {
UPDATE_PAGE: {
target: ".gettingWorkspaces",
actions: "updateURL",
},
},
},
},
@@ -367,9 +362,20 @@ export const workspacesMachine =
return spawn(workspaceItemMachine.withContext({ data }), data.id)
}),
}),
assignPaginationRef: assign({
paginationRef: (context) =>
spawn(
paginationMachine.withContext(context.paginationContext),
workspacePaginationId,
),
}),
assignFilter: assign({
filter: (context, event) => event.query ?? context.filter,
}),
sendResetPage: send(
{ type: "RESET_PAGE" },
{ to: workspacePaginationId },
),
assignGetWorkspacesError: assign({
getWorkspacesError: (_, event) => event.data,
}),
@@ -397,18 +403,6 @@ export const workspacesMachine =
return event.data.refsToKeep.concat(newWorkspaceRefs)
},
}),
assignNextPage: assign({
page: (context) => context.page + 1,
}),
assignPreviousPage: assign({
page: (context) => context.page - 1,
}),
assignPage: assign({
page: (_, event) => event.page,
}),
resetPage: assign({
page: (_) => 1,
}),
assignCount: assign({
count: (_, event) => event.data.count,
}),
@@ -420,12 +414,18 @@ export const workspacesMachine =
}),
},
services: {
getWorkspaces: (context) =>
API.getWorkspaces({
...queryToFilter(context.filter),
offset: (context.page - 1) * context.limit,
limit: context.limit,
}),
getWorkspaces: (context) => {
if (context.paginationRef) {
const { offset, limit } = getPaginationData(context.paginationRef)
return API.getWorkspaces({
...queryToFilter(context.filter),
offset,
limit,
})
} else {
throw new Error("Cannot get workspaces without pagination data")
}
},
updateWorkspaceRefs: (context, event) => {
const refsToKeep: WorkspaceItemMachineRef[] = []
context.workspaceRefs?.forEach((ref) => {