feat: paginating Users page (#4792)

* 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

* Start adding pagination - type error

* Tweak machine

* Refactor paginated api calls

* Show pagination when count is undefined

* fix stories

* Fix api helper

* Add test

* Format

* Make widget show all the time to avoid blink
This commit is contained in:
Presley Pizzo
2022-10-28 15:43:10 -04:00
committed by GitHub
parent 708abd37cf
commit 506a81e3dc
8 changed files with 568 additions and 481 deletions
+29 -52
View File
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
@@ -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
@@ -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>
@@ -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 () => {
+10 -11
View File
@@ -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"
@@ -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 {
@@ -39,6 +45,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
userIdToActivate,
userIdToResetPassword,
newUserPassword,
paginationRef,
} = usersState.context
const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend)
@@ -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
@@ -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 && (
@@ -1,6 +1,7 @@
import { ComponentMeta, Story } from "@storybook/react"
import { createPaginationRef } from "components/PaginationWidget/utils"
import {
MockSiteRoles,
MockAssignableSiteRoles,
MockUser,
MockUser2,
} from "../../testHelpers/renderHelpers"
@@ -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) => (
@@ -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,
}
@@ -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: [] }
@@ -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"
@@ -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>> = ({
@@ -43,6 +46,7 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
isLoading,
filter,
onFilter,
paginationRef,
}) => {
const presetFilters = [
{ query: userFilterQuery.active, name: Language.activeUsersFilterName },
@@ -71,6 +75,8 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
canEditUsers={canEditUsers}
isLoading={isLoading}
/>
<PaginationWidget paginationRef={paginationRef} />
</>
)
}
+6 -2
View File
@@ -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(
@@ -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,
}
+459 -387
View File
@@ -1,4 +1,10 @@
import { assign, createMachine } from "xstate"
import { getPaginationData } from "components/PaginationWidget/utils"
import {
PaginationContext,
paginationMachine,
PaginationMachineRef,
} from "xServices/pagination/paginationXService"
import { assign, createMachine, send, spawn } from "xstate"
import * as API from "../../api/api"
import { getErrorMessage } from "../../api/errors"
import * as TypesGen from "../../api/typesGenerated"
@@ -9,6 +15,8 @@ import {
import { queryToFilter } from "../../util/filters"
import { generateRandomString } from "../../util/random"
const usersPaginationId = "usersPagination"
export const Language = {
getUsersError: "Error getting users.",
suspendUserSuccess: "Successfully suspended the user.",
@@ -26,7 +34,7 @@ export const Language = {
export interface UsersContext {
// Get users
users?: TypesGen.User[]
filter?: string
filter: string
getUsersError?: Error | unknown
// Suspend user
userIdToSuspend?: TypesGen.User["id"]
@@ -44,6 +52,8 @@ export interface UsersContext {
// Update user roles
userIdToUpdateRoles?: TypesGen.User["id"]
updateUserRolesError?: Error | unknown
paginationContext: PaginationContext
paginationRef: PaginationMachineRef
}
export type UsersEvent =
@@ -70,393 +80,455 @@ export type UsersEvent =
userId: TypesGen.User["id"]
roles: TypesGen.Role["name"][]
}
// Filter
| { type: "UPDATE_FILTER"; query: string }
// Pagination
| { type: "UPDATE_PAGE"; page: string }
export const usersMachine = createMachine(
{
id: "usersState",
predictableActionArguments: true,
tsTypes: {} as import("./usersXService.typegen").Typegen0,
schema: {
context: {} as UsersContext,
events: {} as UsersEvent,
services: {} as {
getUsers: {
data: TypesGen.User[]
}
createUser: {
data: TypesGen.User
}
suspendUser: {
data: TypesGen.User
}
deleteUser: {
data: undefined
}
activateUser: {
data: TypesGen.User
}
updateUserPassword: {
data: undefined
}
updateUserRoles: {
data: TypesGen.User
}
},
},
initial: "gettingUsers",
states: {
gettingUsers: {
entry: "clearGetUsersError",
invoke: {
src: "getUsers",
id: "getUsers",
onDone: [
{
target: "#usersState.idle",
actions: "assignUsers",
},
],
onError: [
{
actions: [
"clearUsers",
"assignGetUsersError",
"displayGetUsersErrorMessage",
],
target: "#usersState.error",
},
],
},
tags: "loading",
},
idle: {
on: {
GET_USERS: {
actions: "assignFilter",
target: "gettingUsers",
},
SUSPEND_USER: {
target: "confirmUserSuspension",
actions: ["assignUserIdToSuspend"],
},
DELETE_USER: {
target: "confirmUserDeletion",
actions: ["assignUserIdToDelete"],
},
ACTIVATE_USER: {
target: "confirmUserActivation",
actions: ["assignUserIdToActivate"],
},
RESET_USER_PASSWORD: {
target: "confirmUserPasswordReset",
actions: ["assignUserIdToResetPassword", "generateRandomPassword"],
},
UPDATE_USER_ROLES: {
target: "updatingUserRoles",
actions: ["assignUserIdToUpdateRoles"],
},
},
},
confirmUserSuspension: {
on: {
CONFIRM_USER_SUSPENSION: "suspendingUser",
CANCEL_USER_SUSPENSION: "idle",
},
},
confirmUserDeletion: {
on: {
CONFIRM_USER_DELETE: "deletingUser",
CANCEL_USER_DELETE: "idle",
},
},
confirmUserActivation: {
on: {
CONFIRM_USER_ACTIVATION: "activatingUser",
CANCEL_USER_ACTIVATION: "idle",
},
},
suspendingUser: {
entry: "clearSuspendUserError",
invoke: {
src: "suspendUser",
id: "suspendUser",
onDone: {
// Update users list
target: "gettingUsers",
actions: ["displaySuspendSuccess"],
},
onError: {
target: "idle",
actions: ["assignSuspendUserError", "displaySuspendedErrorMessage"],
},
},
},
deletingUser: {
entry: "clearDeleteUserError",
invoke: {
src: "deleteUser",
id: "deleteUser",
onDone: {
target: "gettingUsers",
actions: ["displayDeleteSuccess"],
},
onError: {
target: "idle",
actions: ["assignDeleteUserError", "displayDeleteErrorMessage"],
},
},
},
activatingUser: {
entry: "clearActivateUserError",
invoke: {
src: "activateUser",
id: "activateUser",
onDone: {
// Update users list
target: "gettingUsers",
actions: ["displayActivateSuccess"],
},
onError: {
target: "idle",
actions: [
"assignActivateUserError",
"displayActivatedErrorMessage",
],
},
},
},
confirmUserPasswordReset: {
on: {
CONFIRM_USER_PASSWORD_RESET: "resettingUserPassword",
CANCEL_USER_PASSWORD_RESET: "idle",
},
},
resettingUserPassword: {
entry: "clearResetUserPasswordError",
invoke: {
src: "resetUserPassword",
id: "resetUserPassword",
onDone: {
target: "idle",
actions: ["displayResetPasswordSuccess"],
},
onError: {
target: "idle",
actions: [
"assignResetUserPasswordError",
"displayResetPasswordErrorMessage",
],
},
},
},
updatingUserRoles: {
entry: "clearUpdateUserRolesError",
invoke: {
src: "updateUserRoles",
id: "updateUserRoles",
onDone: {
target: "idle",
actions: ["updateUserRolesInTheList"],
},
onError: {
target: "idle",
actions: [
"assignUpdateRolesError",
"displayUpdateRolesErrorMessage",
],
},
},
},
error: {
on: {
GET_USERS: {
actions: "assignFilter",
target: "gettingUsers",
},
},
},
},
},
{
services: {
// Passing API.getUsers directly does not invoke the function properly
// when it is mocked. This happen in the UsersPage tests inside of the
// "shows a success message and refresh the page" test case.
getUsers: (context) => API.getUsers(queryToFilter(context.filter)),
suspendUser: (context) => {
if (!context.userIdToSuspend) {
throw new Error("userIdToSuspend is undefined")
}
return API.suspendUser(context.userIdToSuspend)
},
deleteUser: (context) => {
if (!context.userIdToDelete) {
throw new Error("userIdToDelete is undefined")
}
return API.deleteUser(context.userIdToDelete)
},
activateUser: (context) => {
if (!context.userIdToActivate) {
throw new Error("userIdToActivate is undefined")
}
return API.activateUser(context.userIdToActivate)
},
resetUserPassword: (context) => {
if (!context.userIdToResetPassword) {
throw new Error("userIdToResetPassword is undefined")
}
if (!context.newUserPassword) {
throw new Error("newUserPassword not generated")
}
return API.updateUserPassword(context.userIdToResetPassword, {
password: context.newUserPassword,
old_password: "",
})
},
updateUserRoles: (context, event) => {
if (!context.userIdToUpdateRoles) {
throw new Error("userIdToUpdateRoles is undefined")
}
return API.updateUserRoles(event.roles, context.userIdToUpdateRoles)
},
},
actions: {
assignUsers: assign({
users: (_, event) => event.data,
}),
assignFilter: assign({
filter: (_, event) => event.query,
}),
assignGetUsersError: assign({
getUsersError: (_, event) => event.data,
}),
assignUserIdToSuspend: assign({
userIdToSuspend: (_, event) => event.userId,
}),
assignUserIdToDelete: assign({
userIdToDelete: (_, event) => event.userId,
}),
assignUserIdToActivate: assign({
userIdToActivate: (_, event) => event.userId,
}),
assignUserIdToResetPassword: assign({
userIdToResetPassword: (_, event) => event.userId,
}),
assignUserIdToUpdateRoles: assign({
userIdToUpdateRoles: (_, event) => event.userId,
}),
clearGetUsersError: assign((context: UsersContext) => ({
...context,
getUsersError: undefined,
})),
assignSuspendUserError: assign({
suspendUserError: (_, event) => event.data,
}),
assignDeleteUserError: assign({
deleteUserError: (_, event) => event.data,
}),
assignActivateUserError: assign({
activateUserError: (_, event) => event.data,
}),
assignResetUserPasswordError: assign({
resetUserPasswordError: (_, event) => event.data,
}),
assignUpdateRolesError: assign({
updateUserRolesError: (_, event) => event.data,
}),
clearUsers: assign((context: UsersContext) => ({
...context,
users: undefined,
})),
clearSuspendUserError: assign({
suspendUserError: (_) => undefined,
}),
clearDeleteUserError: assign({
deleteUserError: (_) => undefined,
}),
clearActivateUserError: assign({
activateUserError: (_) => undefined,
}),
clearResetUserPasswordError: assign({
resetUserPasswordError: (_) => undefined,
}),
clearUpdateUserRolesError: assign({
updateUserRolesError: (_) => undefined,
}),
displayGetUsersErrorMessage: (context) => {
const message = getErrorMessage(
context.getUsersError,
Language.getUsersError,
)
displayError(message)
},
displaySuspendSuccess: () => {
displaySuccess(Language.suspendUserSuccess)
},
displaySuspendedErrorMessage: (context) => {
const message = getErrorMessage(
context.suspendUserError,
Language.suspendUserError,
)
displayError(message)
},
displayDeleteSuccess: () => {
displaySuccess(Language.deleteUserSuccess)
},
displayDeleteErrorMessage: (context) => {
const message = getErrorMessage(
context.deleteUserError,
Language.deleteUserError,
)
displayError(message)
},
displayActivateSuccess: () => {
displaySuccess(Language.activateUserSuccess)
},
displayActivatedErrorMessage: (context) => {
const message = getErrorMessage(
context.activateUserError,
Language.activateUserError,
)
displayError(message)
},
displayResetPasswordSuccess: () => {
displaySuccess(Language.resetUserPasswordSuccess)
},
displayResetPasswordErrorMessage: (context) => {
const message = getErrorMessage(
context.resetUserPasswordError,
Language.resetUserPasswordError,
)
displayError(message)
},
displayUpdateRolesErrorMessage: (context) => {
const message = getErrorMessage(
context.updateUserRolesError,
Language.updateUserRolesError,
)
displayError(message)
},
generateRandomPassword: assign({
newUserPassword: (_) => generateRandomString(12),
}),
updateUserRolesInTheList: assign({
users: ({ users }, event) => {
if (!users) {
return users
export const usersMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QFdZgE6wMoBcCGOYAdLPujgJYB2UACnlNQRQPZUDEA2gAwC6ioAA4tYFSmwEgAHogCMADiLcA7AFYVAFgCcW5bNkaAbACZDAGhABPOVsNEAzN1XGDhw92PduW+wF9fFqgY2PiERDA4lDQAqmiY7BBsxNQAbiwA1sQRscE8-EggwqLiVJIyCPKyRHryyk4m9lrGzfYW1ggaGqpERvaq9soasqrKfgEgQZi4BFlgkdRQOfEY6CzoRIIANgQAZmsAtuFzS7B5kkVirKUF5QC0ssrGRLV1rs2VyrptiJ3K1cbyHQaUyqSoDVT+QJxEIzIgUCCbMDsLDRLC0ACiADkACIAfVR6IASmcChcSmVEMZOkRDPpgYZ5J4NNx5PZWlY5A8qvZjH15Nx7IZVFp5KoNJCJtDpmF4Yj2Nj0QAZdEAFXR+KwRJJQhElwkN0phi0RAMqncjIZo0Z3wqtSIOgZGg+3HNsglkxhMoRSIAggBhFUASQAaj61RqtXxzrryQaEKZ7Pb7KLBQCTLJ3Kobe5E2adINjE1DE7jO6paFkt72IT0ZqVRHCbjaD6sFgAOoAeUJ2O1hRjVwp8dkxq6A2UFuTyhMWY5CFcdlkhbB8n5vNBZeC0srcuitGxYfVBMbhI7yqwvbJA7jtwBfwF3lG-WHPhtjO4NIZ7lk9Q0fI3UwrOEq13fdw2bABxdEL37fVQDuHk7BUTQnEcAEdGzLoaQtdQ+h8Ywp3-T1tyRECD1xAAxQNFTVYko1JGDrjgxB7m6LQjABfCtBUYduFkV9h2qAZv3w5wuIhcYPS3IgAGM2B2Ch0H2JYsFQQQwCoUQ2HYP0O0xSjCQAWQbXEUTRLEsEDXToOKK8mNtRMCyFXpPjcLQbX0FcTU+EtnFwhlCKk2SqHkxTlNU9TNI4P0fUxP0lWM0yMUxCyrLonUbNg6REFBbpmU+LiHipbhOnc-D3xXUURVpWlioCwCgpCpS4mxMBERKbTdP0oyj1xBVlTVay9UYrKKh8e1vHkbRgRUBobS0fQelFRc9F-Nj5EMOrYQahSmowFq2qubSYrixVjL61UoLSvsMuG8paSeYZ7AzNlDHHN65rcGliv5AVPjNExNrCbbQriH1pMoFJmC0nS9MDQzjP9INQyDVL8nSobBxeD8BnsTops6NzZ0MXHFrZKk2NUfQNok8strknaljBiGoai474p6xGQzDSzMUG2M7OFVjuMXQVeNUSmbTqRNlt-NwnuUR5xRpzdANgcKqAgBYlgSJI4SoNJMhIdWICWPnbJG4ZFxNcX+XHbxhzUOa6i8o1vBFdRaTdZWANhNXYDUjWtbidgVjWDZthwPZFKN-31JNuIzcy8oPPfEYXEm3R8NGPjZ1kRwNCUYU8-w2Xv3EqEVdhCBWrmIOMB1qhkn1jJiGrtqwFNq7LyTuRKeNAxmS0UENEeXjJZGapmRGEfVrTwHW5rqJFmD0P1i2XYDiINu5g7hOu4Ywd9CH6oWVFV4uItdzeONMU6TZWpFxH+eiDwcGKEhpftcSRu9YN4hX+ZoQTuaNroYzjJbRMPgeQujUOob85hZxmlTrSEYLpTAKDNM-AB79mAxBXugVYa8I5R0ONgj+u8MCJ1upyWwk9vwsmTMVAw48C7aAZFyMUU59DP2BrtdA9BYCwAAO5rAgISOAcwOqw3hj1ZsrZOzdlxDWOsVDMZimqPAgwxhKbixFO5HCX1ibaABJTV6ygeH0xBhgARwjRHiLQDgI6sV2aakbHI9sXY8TKNVKouMad7T9FsMTI0Csnr6LtAYUWahFxMnMd7IiRB0ASPmHg6xeBBEiPQBABuTc-6JOSUsGxmSIC+LsnndRecmjqD0KMfC7lxwLlMLofot5RTUwrj7MISSHGfziEU0RIcCFh3XpHTe3Tjh9PSbYrJpSLYPGNCoRwoJ3iihXO5IUTwRS6BqMWAYwJn7IEEBAXBy8MCEhYIiWAOTf4tyIIc45QC4jnMubMu4VoTTFSFG+SpGgPp-CnFA4SzhtDl0lJXMI9yTlLGeXAQZhDw4b2jpCx5ZyLlwFecxL8Dghi6FegoPogp+LGlGHoDwahCxOH8OMKgLBq7wAKJJVWZAl70EYFQFm0YbqDluM4E+nxPgsmLCKXkr5uiFktDob8zJXpKw6QkiIvTgicrAXZW47geiSqGHigEi4ZztBBEQfobIjWeBiVoZ+sowDKv5hbHkzwmiDB5M0NwYpfmzjUAXZwZ9OjfjYk9CxwUGZxBUrHDS5tu7UIQKCRQXEhgjzluUnO7Qj4F0qBgqBeF2lgs6cQXhSx9q10yhGwcJgnhGlpI8IeT5xZzQWk6dQ6h+QPHFmMOVgVLF8KZjgm1xa4zVUnkaZkuMvBDD1YgIxpMeRTUphmZ+fsA6a1Sega15tk4ZkUEXV2TgxTCnkO5YYfwnoKHFVSSccS22AW3oq5d9EuXgIUFUHQvFTCvAMHo2cK4C4eCEn0dwXQHhYLfh-OuN70Y2rXWNbRDribzSNUm8dngTTfkBLyFB8aA2NUKVM4p9i5grp7lG+ahq1AijzsCcl8G5wGPcEYpoS0zHP3GSk05-DsOiPw5GjyC5nBeFZOaDwY65xsI-GoWoDb1pqAOUcqFTy0X0rA6u5inR3xrlsCyOoAohhzSMPaGpr03C8TZPPDj3KGkfKMMswzbEbS3AcgKRcSEnArkGPIKlvggA */
createMachine(
{
tsTypes: {} as import("./usersXService.typegen").Typegen0,
schema: {
context: {} as UsersContext,
events: {} as UsersEvent,
services: {} as {
getUsers: {
data: TypesGen.User[]
}
return users.map((u) => {
return u.id === event.data.id ? event.data : u
createUser: {
data: TypesGen.User
}
suspendUser: {
data: TypesGen.User
}
deleteUser: {
data: undefined
}
activateUser: {
data: TypesGen.User
}
updateUserPassword: {
data: undefined
}
updateUserRoles: {
data: TypesGen.User
}
},
},
predictableActionArguments: true,
id: "usersState",
initial: "startingPagination",
states: {
startingPagination: {
entry: "assignPaginationRef",
always: {
target: "gettingUsers",
},
},
gettingUsers: {
entry: "clearGetUsersError",
invoke: {
src: "getUsers",
id: "getUsers",
onDone: [
{
target: "idle",
actions: "assignUsers",
},
],
onError: [
{
target: "idle",
actions: [
"clearUsers",
"assignGetUsersError",
"displayGetUsersErrorMessage",
],
},
],
},
tags: "loading",
},
idle: {
on: {
SUSPEND_USER: {
target: "confirmUserSuspension",
actions: "assignUserIdToSuspend",
},
DELETE_USER: {
target: "confirmUserDeletion",
actions: "assignUserIdToDelete",
},
ACTIVATE_USER: {
target: "confirmUserActivation",
actions: "assignUserIdToActivate",
},
RESET_USER_PASSWORD: {
target: "confirmUserPasswordReset",
actions: [
"assignUserIdToResetPassword",
"generateRandomPassword",
],
},
UPDATE_USER_ROLES: {
target: "updatingUserRoles",
actions: "assignUserIdToUpdateRoles",
},
UPDATE_PAGE: {
target: "gettingUsers",
actions: "updateURL",
},
UPDATE_FILTER: {
actions: ["assignFilter", "sendResetPage"],
},
},
},
confirmUserSuspension: {
on: {
CONFIRM_USER_SUSPENSION: {
target: "suspendingUser",
},
CANCEL_USER_SUSPENSION: {
target: "idle",
},
},
},
confirmUserDeletion: {
on: {
CONFIRM_USER_DELETE: {
target: "deletingUser",
},
CANCEL_USER_DELETE: {
target: "idle",
},
},
},
confirmUserActivation: {
on: {
CONFIRM_USER_ACTIVATION: {
target: "activatingUser",
},
CANCEL_USER_ACTIVATION: {
target: "idle",
},
},
},
suspendingUser: {
entry: "clearSuspendUserError",
invoke: {
src: "suspendUser",
id: "suspendUser",
onDone: [
{
target: "gettingUsers",
actions: "displaySuspendSuccess",
},
],
onError: [
{
target: "idle",
actions: [
"assignSuspendUserError",
"displaySuspendedErrorMessage",
],
},
],
},
},
deletingUser: {
entry: "clearDeleteUserError",
invoke: {
src: "deleteUser",
id: "deleteUser",
onDone: [
{
target: "gettingUsers",
actions: "displayDeleteSuccess",
},
],
onError: [
{
target: "idle",
actions: ["assignDeleteUserError", "displayDeleteErrorMessage"],
},
],
},
},
activatingUser: {
entry: "clearActivateUserError",
invoke: {
src: "activateUser",
id: "activateUser",
onDone: [
{
target: "gettingUsers",
actions: "displayActivateSuccess",
},
],
onError: [
{
target: "idle",
actions: [
"assignActivateUserError",
"displayActivatedErrorMessage",
],
},
],
},
},
confirmUserPasswordReset: {
on: {
CONFIRM_USER_PASSWORD_RESET: {
target: "resettingUserPassword",
},
CANCEL_USER_PASSWORD_RESET: {
target: "idle",
},
},
},
resettingUserPassword: {
entry: "clearResetUserPasswordError",
invoke: {
src: "resetUserPassword",
id: "resetUserPassword",
onDone: [
{
target: "idle",
actions: "displayResetPasswordSuccess",
},
],
onError: [
{
target: "idle",
actions: [
"assignResetUserPasswordError",
"displayResetPasswordErrorMessage",
],
},
],
},
},
updatingUserRoles: {
entry: "clearUpdateUserRolesError",
invoke: {
src: "updateUserRoles",
id: "updateUserRoles",
onDone: [
{
target: "idle",
actions: "updateUserRolesInTheList",
},
],
onError: [
{
target: "idle",
actions: [
"assignUpdateRolesError",
"displayUpdateRolesErrorMessage",
],
},
],
},
},
},
},
{
services: {
// Passing API.getUsers directly does not invoke the function properly
// when it is mocked. This happen in the UsersPage tests inside of the
// "shows a success message and refresh the page" test case.
getUsers: (context) => {
const { offset, limit } = getPaginationData(context.paginationRef)
return API.getUsers({
...queryToFilter(context.filter),
offset,
limit,
})
},
}),
suspendUser: (context) => {
if (!context.userIdToSuspend) {
throw new Error("userIdToSuspend is undefined")
}
return API.suspendUser(context.userIdToSuspend)
},
deleteUser: (context) => {
if (!context.userIdToDelete) {
throw new Error("userIdToDelete is undefined")
}
return API.deleteUser(context.userIdToDelete)
},
activateUser: (context) => {
if (!context.userIdToActivate) {
throw new Error("userIdToActivate is undefined")
}
return API.activateUser(context.userIdToActivate)
},
resetUserPassword: (context) => {
if (!context.userIdToResetPassword) {
throw new Error("userIdToResetPassword is undefined")
}
if (!context.newUserPassword) {
throw new Error("newUserPassword not generated")
}
return API.updateUserPassword(context.userIdToResetPassword, {
password: context.newUserPassword,
old_password: "",
})
},
updateUserRoles: (context, event) => {
if (!context.userIdToUpdateRoles) {
throw new Error("userIdToUpdateRoles is undefined")
}
return API.updateUserRoles(event.roles, context.userIdToUpdateRoles)
},
},
actions: {
assignUsers: assign({
users: (_, event) => event.data,
}),
assignFilter: assign({
filter: (_, event) => event.query,
}),
assignGetUsersError: assign({
getUsersError: (_, event) => event.data,
}),
assignUserIdToSuspend: assign({
userIdToSuspend: (_, event) => event.userId,
}),
assignUserIdToDelete: assign({
userIdToDelete: (_, event) => event.userId,
}),
assignUserIdToActivate: assign({
userIdToActivate: (_, event) => event.userId,
}),
assignUserIdToResetPassword: assign({
userIdToResetPassword: (_, event) => event.userId,
}),
assignUserIdToUpdateRoles: assign({
userIdToUpdateRoles: (_, event) => event.userId,
}),
clearGetUsersError: assign((context: UsersContext) => ({
...context,
getUsersError: undefined,
})),
assignSuspendUserError: assign({
suspendUserError: (_, event) => event.data,
}),
assignDeleteUserError: assign({
deleteUserError: (_, event) => event.data,
}),
assignActivateUserError: assign({
activateUserError: (_, event) => event.data,
}),
assignResetUserPasswordError: assign({
resetUserPasswordError: (_, event) => event.data,
}),
assignUpdateRolesError: assign({
updateUserRolesError: (_, event) => event.data,
}),
clearUsers: assign((context: UsersContext) => ({
...context,
users: undefined,
})),
clearSuspendUserError: assign({
suspendUserError: (_) => undefined,
}),
clearDeleteUserError: assign({
deleteUserError: (_) => undefined,
}),
clearActivateUserError: assign({
activateUserError: (_) => undefined,
}),
clearResetUserPasswordError: assign({
resetUserPasswordError: (_) => undefined,
}),
clearUpdateUserRolesError: assign({
updateUserRolesError: (_) => undefined,
}),
displayGetUsersErrorMessage: (context) => {
const message = getErrorMessage(
context.getUsersError,
Language.getUsersError,
)
displayError(message)
},
displaySuspendSuccess: () => {
displaySuccess(Language.suspendUserSuccess)
},
displaySuspendedErrorMessage: (context) => {
const message = getErrorMessage(
context.suspendUserError,
Language.suspendUserError,
)
displayError(message)
},
displayDeleteSuccess: () => {
displaySuccess(Language.deleteUserSuccess)
},
displayDeleteErrorMessage: (context) => {
const message = getErrorMessage(
context.deleteUserError,
Language.deleteUserError,
)
displayError(message)
},
displayActivateSuccess: () => {
displaySuccess(Language.activateUserSuccess)
},
displayActivatedErrorMessage: (context) => {
const message = getErrorMessage(
context.activateUserError,
Language.activateUserError,
)
displayError(message)
},
displayResetPasswordSuccess: () => {
displaySuccess(Language.resetUserPasswordSuccess)
},
displayResetPasswordErrorMessage: (context) => {
const message = getErrorMessage(
context.resetUserPasswordError,
Language.resetUserPasswordError,
)
displayError(message)
},
displayUpdateRolesErrorMessage: (context) => {
const message = getErrorMessage(
context.updateUserRolesError,
Language.updateUserRolesError,
)
displayError(message)
},
generateRandomPassword: assign({
newUserPassword: (_) => generateRandomString(12),
}),
updateUserRolesInTheList: assign({
users: ({ users }, event) => {
if (!users) {
return users
}
return users.map((u) => {
return u.id === event.data.id ? event.data : u
})
},
}),
assignPaginationRef: assign({
paginationRef: (context) =>
spawn(
paginationMachine.withContext(context.paginationContext),
usersPaginationId,
),
}),
sendResetPage: send({ type: "RESET_PAGE" }, { to: usersPaginationId }),
},
},
},
)
)