diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 8bb9303f97fa2..3804e83aed2aa 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -132,10 +132,10 @@ export const getApiKey = async (): Promise => { } export const getUsers = async ( - filter?: TypesGen.UsersRequest, + options: TypesGen.UsersRequest, ): Promise => { - const url = getURLWithSearchParams("/api/v2/users", filter) - const response = await axios.get(url) + const url = getURLWithSearchParams("/api/v2/users", options) + const response = await axios.get(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 => { - 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/workspaces?${searchParams.toString()}`, - ) + const url = getURLWithSearchParams("/api/v2/workspaces", options) + const response = await axios.get(url) return response.data } export const getWorkspacesCount = async ( options: TypesGen.WorkspaceCountRequest, ): Promise => { - 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 => { export const getAuditLogs = async ( options: TypesGen.AuditLogsRequest, ): Promise => { - 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 => { - 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 } diff --git a/site/src/components/PaginationWidget/PaginationWidget.tsx b/site/src/components/PaginationWidget/PaginationWidget.tsx index 64ad521d6d067..e3765e524a398 100644 --- a/site/src/components/PaginationWidget/PaginationWidget.tsx +++ b/site/src/components/PaginationWidget/PaginationWidget.tsx @@ -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 (
- 0}> + - {buildPagedList(numPages, currentPage).map((page) => - typeof page !== "number" ? ( - - ) : ( - send({ type: "GO_TO_PAGE", page })} - /> - ), - )} + {numPages && + buildPagedList(numPages, currentPage).map((page) => + typeof page !== "number" ? ( + + ) : ( + send({ type: "GO_TO_PAGE", page })} + /> + ), + )} diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index a15267ffd9822..766581ca2dcf2 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -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 () => { diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index ce28b51032295..63384c13118e5 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -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 && ( diff --git a/site/src/pages/UsersPage/UsersPageView.stories.tsx b/site/src/pages/UsersPage/UsersPageView.stories.tsx index ed8ee67f81324..93b06cee66f57 100644 --- a/site/src/pages/UsersPage/UsersPageView.stories.tsx +++ b/site/src/pages/UsersPage/UsersPageView.stories.tsx @@ -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 const Template: Story = (args) => ( @@ -18,8 +24,7 @@ const Template: Story = (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: [] } diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 94e84d9b4cf79..9a80140a1d048 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -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> = ({ @@ -43,6 +46,7 @@ export const UsersPageView: FC> = ({ isLoading, filter, onFilter, + paginationRef, }) => { const presetFilters = [ { query: userFilterQuery.active, name: Language.activeUsersFilterName }, @@ -71,6 +75,8 @@ export const UsersPageView: FC> = ({ canEditUsers={canEditUsers} isLoading={isLoading} /> + + ) } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 93d380e255e37..f0f51152b963b 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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, } diff --git a/site/src/xServices/users/usersXService.ts b/site/src/xServices/users/usersXService.ts index 153523465d048..764d3124fab75 100644 --- a/site/src/xServices/users/usersXService.ts +++ b/site/src/xServices/users/usersXService.ts @@ -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", - }, - ], +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[] + } + createUser: { + data: TypesGen.User + } + suspendUser: { + data: TypesGen.User + } + deleteUser: { + data: undefined + } + activateUser: { + data: TypesGen.User + } + updateUserPassword: { + data: undefined + } + updateUserRoles: { + data: TypesGen.User + } }, - tags: "loading", }, - idle: { - on: { - GET_USERS: { - actions: "assignFilter", + predictableActionArguments: true, + id: "usersState", + initial: "startingPagination", + states: { + startingPagination: { + entry: "assignPaginationRef", + always: { 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"], + gettingUsers: { + entry: "clearGetUsersError", + invoke: { + src: "getUsers", + id: "getUsers", + onDone: [ + { + target: "idle", + actions: "assignUsers", + }, + ], + onError: [ + { + target: "idle", + actions: [ + "clearUsers", + "assignGetUsersError", + "displayGetUsersErrorMessage", + ], + }, + ], }, - onError: { - target: "idle", - actions: ["assignSuspendUserError", "displaySuspendedErrorMessage"], + 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"], + }, }, }, - }, - deletingUser: { - entry: "clearDeleteUserError", - invoke: { - src: "deleteUser", - id: "deleteUser", - onDone: { - target: "gettingUsers", - actions: ["displayDeleteSuccess"], + confirmUserSuspension: { + on: { + CONFIRM_USER_SUSPENSION: { + target: "suspendingUser", + }, + CANCEL_USER_SUSPENSION: { + target: "idle", + }, }, - onError: { - target: "idle", - actions: ["assignDeleteUserError", "displayDeleteErrorMessage"], + }, + confirmUserDeletion: { + on: { + CONFIRM_USER_DELETE: { + target: "deletingUser", + }, + CANCEL_USER_DELETE: { + target: "idle", + }, }, }, - }, - activatingUser: { - entry: "clearActivateUserError", - invoke: { - src: "activateUser", - id: "activateUser", - onDone: { - // Update users list - target: "gettingUsers", - actions: ["displayActivateSuccess"], + confirmUserActivation: { + on: { + CONFIRM_USER_ACTIVATION: { + target: "activatingUser", + }, + CANCEL_USER_ACTIVATION: { + target: "idle", + }, }, - onError: { - target: "idle", - actions: [ - "assignActivateUserError", - "displayActivatedErrorMessage", + }, + suspendingUser: { + entry: "clearSuspendUserError", + invoke: { + src: "suspendUser", + id: "suspendUser", + onDone: [ + { + target: "gettingUsers", + actions: "displaySuspendSuccess", + }, + ], + onError: [ + { + target: "idle", + actions: [ + "assignSuspendUserError", + "displaySuspendedErrorMessage", + ], + }, ], }, }, - }, - 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"], + deletingUser: { + entry: "clearDeleteUserError", + invoke: { + src: "deleteUser", + id: "deleteUser", + onDone: [ + { + target: "gettingUsers", + actions: "displayDeleteSuccess", + }, + ], + onError: [ + { + target: "idle", + actions: ["assignDeleteUserError", "displayDeleteErrorMessage"], + }, + ], }, - onError: { - target: "idle", - actions: [ - "assignResetUserPasswordError", - "displayResetPasswordErrorMessage", + }, + activatingUser: { + entry: "clearActivateUserError", + invoke: { + src: "activateUser", + id: "activateUser", + onDone: [ + { + target: "gettingUsers", + actions: "displayActivateSuccess", + }, + ], + onError: [ + { + target: "idle", + actions: [ + "assignActivateUserError", + "displayActivatedErrorMessage", + ], + }, ], }, }, - }, - updatingUserRoles: { - entry: "clearUpdateUserRolesError", - invoke: { - src: "updateUserRoles", - id: "updateUserRoles", - onDone: { - target: "idle", - actions: ["updateUserRolesInTheList"], + confirmUserPasswordReset: { + on: { + CONFIRM_USER_PASSWORD_RESET: { + target: "resettingUserPassword", + }, + CANCEL_USER_PASSWORD_RESET: { + target: "idle", + }, }, - onError: { - target: "idle", - actions: [ - "assignUpdateRolesError", - "displayUpdateRolesErrorMessage", + }, + resettingUserPassword: { + entry: "clearResetUserPasswordError", + invoke: { + src: "resetUserPassword", + id: "resetUserPassword", + onDone: [ + { + target: "idle", + actions: "displayResetPasswordSuccess", + }, + ], + onError: [ + { + target: "idle", + actions: [ + "assignResetUserPasswordError", + "displayResetPasswordErrorMessage", + ], + }, ], }, }, - }, - error: { - on: { - GET_USERS: { - actions: "assignFilter", - target: "gettingUsers", + 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) => 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") - } + { + 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.activateUser(context.userIdToActivate) - }, - resetUserPassword: (context) => { - if (!context.userIdToResetPassword) { - throw new Error("userIdToResetPassword 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") + } - if (!context.newUserPassword) { - throw new Error("newUserPassword not generated") - } + return API.activateUser(context.userIdToActivate) + }, + resetUserPassword: (context) => { + if (!context.userIdToResetPassword) { + throw new Error("userIdToResetPassword is undefined") + } - return API.updateUserPassword(context.userIdToResetPassword, { - password: context.newUserPassword, - old_password: "", - }) - }, - updateUserRoles: (context, event) => { - if (!context.userIdToUpdateRoles) { - throw new Error("userIdToUpdateRoles is undefined") - } + if (!context.newUserPassword) { + throw new Error("newUserPassword not generated") + } - return API.updateUserRoles(event.roles, context.userIdToUpdateRoles) - }, - }, + return API.updateUserPassword(context.userIdToResetPassword, { + password: context.newUserPassword, + old_password: "", + }) + }, + updateUserRoles: (context, event) => { + if (!context.userIdToUpdateRoles) { + throw new Error("userIdToUpdateRoles is undefined") + } - 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) + return API.updateUserRoles(event.roles, context.userIdToUpdateRoles) + }, }, - 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 - }) + 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 }), + }, }, - }, -) + )