Skip to content

feat: paginating Users page #4792

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 34 commits into from
Oct 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
32263f5
Extract PageButton
presleyp Oct 21, 2022
5837c28
Fix import
presleyp Oct 21, 2022
a1303b0
Extract utils
presleyp Oct 21, 2022
ea12615
Format
presleyp Oct 21, 2022
74666ee
Separate pagination - wip
presleyp Oct 21, 2022
40f65ca
Spawn pagination machine - buggy filter
presleyp Oct 25, 2022
eef229e
Make labels optional
presleyp Oct 25, 2022
36ba04d
Layout, fix send reset bug
presleyp Oct 25, 2022
d99f149
Format
presleyp Oct 25, 2022
e4247fe
Fix refresh data bug
presleyp Oct 25, 2022
52b17f2
Remove debugging line
presleyp Oct 25, 2022
c4098f6
Fix url updates
presleyp Oct 25, 2022
b7edc7a
Update Audit Page
presleyp Oct 25, 2022
35b0cd3
Simplify pagination widget
presleyp Oct 25, 2022
ba3783b
Fix workspaces story
presleyp Oct 25, 2022
e92cbbf
Fix Audit story
presleyp Oct 25, 2022
23fdc3c
Fix pagination story and pagebutton highlight
presleyp Oct 25, 2022
1ebab32
Fix pagination tests
presleyp Oct 25, 2022
3f7e0aa
Add to utils tests
presleyp Oct 25, 2022
609af06
Format
presleyp Oct 25, 2022
86cda48
Merge branch 'main' into refactor-pagination/presleyp
presleyp Oct 25, 2022
e005aaf
Add tests
presleyp Oct 26, 2022
5715d38
Start adding pagination - type error
presleyp Oct 26, 2022
9a878fa
Merge branch 'main' into paginate-users/presleyp
presleyp Oct 26, 2022
4271fd1
Tweak machine
presleyp Oct 26, 2022
9a0ce5d
Refactor paginated api calls
presleyp Oct 27, 2022
20d5672
Show pagination when count is undefined
presleyp Oct 27, 2022
b68b46a
fix stories
presleyp Oct 27, 2022
50c5825
Fix api helper
presleyp Oct 27, 2022
2e249a0
Add test
presleyp Oct 27, 2022
953428b
Format
presleyp Oct 27, 2022
9056c44
Make widget show all the time to avoid blink
presleyp Oct 28, 2022
8f4691b
Merge branch 'main' into paginate-users/presleyp
presleyp Oct 28, 2022
b8ca97b
Merge branch 'main' into paginate-users/presleyp
presleyp Oct 28, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 29 additions & 52 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,10 @@ export const getApiKey = async (): Promise<TypesGen.GenerateAPIKeyResponse> => {
}

export const getUsers = async (
filter?: TypesGen.UsersRequest,
options: TypesGen.UsersRequest,
): Promise<TypesGen.User[]> => {
const url = getURLWithSearchParams("/api/v2/users", filter)
const response = await axios.get<TypesGen.User[]>(url)
const url = getURLWithSearchParams("/api/v2/users", options)
const response = await axios.get<TypesGen.User[]>(url.toString())
return response.data
}

Expand Down Expand Up @@ -264,51 +264,43 @@ export const watchWorkspace = (workspaceId: string): EventSource => {
)
}

interface SearchParamOptions extends TypesGen.Pagination {
q?: string
}

export const getURLWithSearchParams = (
basePath: string,
filter?: TypesGen.WorkspaceFilter | TypesGen.UsersRequest,
options?: SearchParamOptions,
): string => {
const searchParams = new URLSearchParams()

if (filter?.q && filter.q !== "") {
searchParams.append("q", filter.q)
if (options) {
const searchParams = new URLSearchParams()
const keys = Object.keys(options) as (keyof SearchParamOptions)[]
keys.forEach((key) => {
const value = options[key]
if (value !== undefined && value !== "") {
searchParams.append(key, value.toString())
}
})
const searchString = searchParams.toString()
return searchString ? `${basePath}?${searchString}` : basePath
} else {
return basePath
}

const searchString = searchParams.toString()

return searchString ? `${basePath}?${searchString}` : basePath
}

export const getWorkspaces = async (
options: TypesGen.WorkspacesRequest,
): Promise<TypesGen.Workspace[]> => {
const searchParams = new URLSearchParams()
if (options.limit) {
searchParams.set("limit", options.limit.toString())
}
if (options.offset) {
searchParams.set("offset", options.offset.toString())
}
if (options.q) {
searchParams.set("q", options.q)
}

const response = await axios.get<TypesGen.Workspace[]>(
`/api/v2/workspaces?${searchParams.toString()}`,
)
const url = getURLWithSearchParams("/api/v2/workspaces", options)
const response = await axios.get<TypesGen.Workspace[]>(url)
return response.data
}

export const getWorkspacesCount = async (
options: TypesGen.WorkspaceCountRequest,
): Promise<TypesGen.WorkspaceCountResponse> => {
const searchParams = new URLSearchParams()
if (options.q) {
searchParams.set("q", options.q)
}
const response = await axios.get(
`/api/v2/workspaces/count?${searchParams.toString()}`,
)
const url = getURLWithSearchParams("/api/v2/workspaces/count", options)
const response = await axios.get(url)
return response.data
}

Expand Down Expand Up @@ -555,31 +547,16 @@ export const getEntitlements = async (): Promise<TypesGen.Entitlements> => {
export const getAuditLogs = async (
options: TypesGen.AuditLogsRequest,
): Promise<TypesGen.AuditLogResponse> => {
const searchParams = new URLSearchParams()
if (options.limit) {
searchParams.set("limit", options.limit.toString())
}
if (options.offset) {
searchParams.set("offset", options.offset.toString())
}
if (options.q) {
searchParams.set("q", options.q)
}

const response = await axios.get(`/api/v2/audit?${searchParams.toString()}`)
const url = getURLWithSearchParams("/api/v2/audit", options)
const response = await axios.get(url)
return response.data
}

export const getAuditLogsCount = async (
options: TypesGen.AuditLogCountRequest = {},
): Promise<TypesGen.AuditLogCountResponse> => {
const searchParams = new URLSearchParams()
if (options.q) {
searchParams.set("q", options.q)
}
const response = await axios.get(
`/api/v2/audit/count?${searchParams.toString()}`,
)
const url = getURLWithSearchParams("/api/v2/audit/count", options)
const response = await axios.get(url)
return response.data
}

Expand Down
48 changes: 23 additions & 25 deletions site/src/components/PaginationWidget/PaginationWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,12 @@ export const PaginationWidget = ({
const currentPage = paginationState.context.page
const numRecordsPerPage = paginationState.context.limit

const numPages = numRecords ? Math.ceil(numRecords / numRecordsPerPage) : 0
const numPages = numRecords
? Math.ceil(numRecords / numRecordsPerPage)
: undefined
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) {
return null
}

return (
<div style={containerStyle} className={styles.defaultContainerStyles}>
<Button
Expand All @@ -54,7 +51,7 @@ export const PaginationWidget = ({
<KeyboardArrowLeft />
<div>{prevLabel}</div>
</Button>
<Maybe condition={numPages > 0}>
<Maybe condition={numPages !== undefined}>
<ChooseOne>
<Cond condition={isMobile}>
<PageButton
Expand All @@ -64,24 +61,25 @@ export const PaginationWidget = ({
/>
</Cond>
<Cond>
{buildPagedList(numPages, currentPage).map((page) =>
typeof page !== "number" ? (
<PageButton
key={`Page${page}`}
activePage={currentPage}
placeholder="..."
disabled
/>
) : (
<PageButton
key={`Page${page}`}
activePage={currentPage}
page={page}
numPages={numPages}
onPageClick={() => send({ type: "GO_TO_PAGE", page })}
/>
),
)}
{numPages &&
buildPagedList(numPages, currentPage).map((page) =>
typeof page !== "number" ? (
<PageButton
key={`Page${page}`}
activePage={currentPage}
placeholder="..."
disabled
/>
) : (
<PageButton
key={`Page${page}`}
activePage={currentPage}
page={page}
numPages={numPages}
onPageClick={() => send({ type: "GO_TO_PAGE", page })}
/>
),
)}
</Cond>
</ChooseOne>
</Maybe>
Expand Down
26 changes: 26 additions & 0 deletions site/src/pages/UsersPage/UsersPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,32 @@ describe("UsersPage", () => {
})
})

describe("pagination", () => {
it("goes to next and previous page", async () => {
renderPage()
const user = userEvent.setup()

const mock = jest
.spyOn(API, "getUsers")
.mockResolvedValueOnce([MockUser, MockUser2])

const nextButton = await screen.findByLabelText("Next page")
await user.click(nextButton)

await waitFor(() =>
expect(API.getUsers).toBeCalledWith({ offset: 25, limit: 25, q: "" }),
)

mock.mockClear()
const previousButton = await screen.findByLabelText("Previous page")
await user.click(previousButton)

await waitFor(() =>
expect(API.getUsers).toBeCalledWith({ offset: 0, limit: 25, q: "" }),
)
})
})

describe("delete user", () => {
describe("when it is success", () => {
it("shows a success message and refresh the page", async () => {
Expand Down
21 changes: 10 additions & 11 deletions site/src/pages/UsersPage/UsersPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useActor, useMachine } from "@xstate/react"
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"
import { getPaginationContext } from "components/PaginationWidget/utils"
import { usePermissions } from "hooks/usePermissions"
import { FC, ReactNode, useContext, useEffect } from "react"
import { Helmet } from "react-helmet-async"
Expand All @@ -25,10 +26,15 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
const xServices = useContext(XServiceContext)
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const filter = searchParams.get("filter") ?? undefined
const filter = searchParams.get("filter") ?? ""
const [usersState, usersSend] = useMachine(usersMachine, {
context: {
filter,
paginationContext: getPaginationContext(searchParams),
},
actions: {
updateURL: (context, event) =>
setSearchParams({ page: event.page, filter: context.filter }),
},
})
const {
Expand All @@ -39,6 +45,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
userIdToActivate,
userIdToResetPassword,
newUserPassword,
paginationRef,
} = usersState.context

const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend)
Expand All @@ -56,14 +63,6 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
usersState.matches("gettingUsers") ||
(canEditUsers && rolesState.matches("gettingRoles"))

// Fetch users on component mount
useEffect(() => {
usersSend({
type: "GET_USERS",
query: filter,
})
}, [filter, usersSend])

// Fetch roles on component mount
useEffect(() => {
// Only fetch the roles if the user has permission for it
Expand Down Expand Up @@ -113,9 +112,9 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
canEditUsers={canEditUsers}
filter={usersState.context.filter}
onFilter={(query) => {
searchParams.set("filter", query)
setSearchParams(searchParams)
usersSend({ type: "UPDATE_FILTER", query })
}}
paginationRef={paginationRef}
/>

{userToBeDeleted && (
Expand Down
13 changes: 9 additions & 4 deletions site/src/pages/UsersPage/UsersPageView.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ComponentMeta, Story } from "@storybook/react"
import { createPaginationRef } from "components/PaginationWidget/utils"
import {
MockSiteRoles,
MockAssignableSiteRoles,
MockUser,
MockUser2,
} from "../../testHelpers/renderHelpers"
Expand All @@ -9,6 +10,11 @@ import { UsersPageView, UsersPageViewProps } from "./UsersPageView"
export default {
title: "pages/UsersPageView",
component: UsersPageView,
argTypes: {
paginationRef: {
defaultValue: createPaginationRef({ page: 1, limit: 25 }),
},
},
} as ComponentMeta<typeof UsersPageView>

const Template: Story<UsersPageViewProps> = (args) => (
Expand All @@ -18,8 +24,7 @@ const Template: Story<UsersPageViewProps> = (args) => (
export const Admin = Template.bind({})
Admin.args = {
users: [MockUser, MockUser2],
roles: MockSiteRoles,
canCreateUser: true,
roles: MockAssignableSiteRoles,
canEditUsers: true,
}

Expand All @@ -32,7 +37,7 @@ SmallViewport.parameters = {
}

export const Member = Template.bind({})
Member.args = { ...Admin.args, canCreateUser: false, canEditUsers: false }
Member.args = { ...Admin.args, canEditUsers: false }

export const Empty = Template.bind({})
Empty.args = { ...Admin.args, users: [] }
Expand Down
6 changes: 6 additions & 0 deletions site/src/pages/UsersPage/UsersPageView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { PaginationWidget } from "components/PaginationWidget/PaginationWidget"
import { FC } from "react"
import { PaginationMachineRef } from "xServices/pagination/paginationXService"
import * as TypesGen from "../../api/typesGenerated"
import { SearchBarWithFilter } from "../../components/SearchBarWithFilter/SearchBarWithFilter"
import { UsersTable } from "../../components/UsersTable/UsersTable"
Expand Down Expand Up @@ -26,6 +28,7 @@ export interface UsersPageViewProps {
roles: TypesGen.Role["name"][],
) => void
onFilter: (query: string) => void
paginationRef: PaginationMachineRef
}

export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
Expand All @@ -43,6 +46,7 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
isLoading,
filter,
onFilter,
paginationRef,
}) => {
const presetFilters = [
{ query: userFilterQuery.active, name: Language.activeUsersFilterName },
Expand Down Expand Up @@ -71,6 +75,8 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
canEditUsers={canEditUsers}
isLoading={isLoading}
/>

<PaginationWidget paginationRef={paginationRef} />
</>
)
}
8 changes: 6 additions & 2 deletions site/src/testHelpers/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,6 @@ export const MockAuditorRole: TypesGen.Role = {
display_name: "Auditor",
}

export const MockSiteRoles = [MockUserAdminRole, MockAuditorRole]

// assignableRole takes a role and a boolean. The boolean implies if the
// actor can assign (add/remove) the role from other users.
export function assignableRole(
Expand All @@ -62,6 +60,12 @@ export function assignableRole(
}
}

export const MockSiteRoles = [MockUserAdminRole, MockAuditorRole]
export const MockAssignableSiteRoles = [
assignableRole(MockUserAdminRole, true),
assignableRole(MockAuditorRole, true),
]

export const MockMemberPermissions = {
viewAuditLog: false,
}
Expand Down
Loading