chore(site): remove users and pagination services (#9932)

This commit is contained in:
Bruno Quaresma
2023-10-02 15:10:51 -03:00
committed by GitHub
parent 885b2502ed
commit 42e25740eb
20 changed files with 433 additions and 1251 deletions
+59 -2
View File
@@ -1,5 +1,13 @@
import { QueryClient } from "@tanstack/react-query";
import * as API from "api/api";
import { UpdateUserPasswordRequest } from "api/typesGenerated";
import { UpdateUserPasswordRequest, UsersRequest } from "api/typesGenerated";
export const users = (req: UsersRequest) => {
return {
queryKey: ["users", req],
queryFn: () => API.getUsers(req),
};
};
export const updatePassword = () => {
return {
@@ -11,9 +19,12 @@ export const updatePassword = () => {
};
};
export const createUser = () => {
export const createUser = (queryClient: QueryClient) => {
return {
mutationFn: API.createUser,
onSuccess: async () => {
await queryClient.invalidateQueries(["users"]);
},
};
};
@@ -22,3 +33,49 @@ export const createFirstUser = () => {
mutationFn: API.createFirstUser,
};
};
export const suspendUser = (queryClient: QueryClient) => {
return {
mutationFn: API.suspendUser,
onSuccess: async () => {
await queryClient.invalidateQueries(["users"]);
},
};
};
export const activateUser = (queryClient: QueryClient) => {
return {
mutationFn: API.activateUser,
onSuccess: async () => {
await queryClient.invalidateQueries(["users"]);
},
};
};
export const deleteUser = (queryClient: QueryClient) => {
return {
mutationFn: API.deleteUser,
onSuccess: async () => {
await queryClient.invalidateQueries(["users"]);
},
};
};
export const updateRoles = (queryClient: QueryClient) => {
return {
mutationFn: ({ userId, roles }: { userId: string; roles: string[] }) =>
API.updateUserRoles(roles, userId),
onSuccess: async () => {
await queryClient.invalidateQueries(["users"]);
},
};
};
export const authMethods = () => {
return {
// Even the endpoint being /users/authmethods we don't want to revalidate it
// when users change so its better add a unique query key
queryKey: ["authMethods"],
queryFn: API.getAuthMethods,
};
};
+12 -14
View File
@@ -353,20 +353,18 @@ const PresetMenu = ({
View advanced filtering
</MenuItem>
{learnMoreLink2 && learnMoreLabel2 && (
<>
<MenuItem
component="a"
href={learnMoreLink2}
target="_blank"
sx={{ fontSize: 13, fontWeight: 500 }}
onClick={() => {
setIsOpen(false);
}}
>
<OpenInNewOutlined sx={{ fontSize: "14px !important" }} />
{learnMoreLabel2}
</MenuItem>
</>
<MenuItem
component="a"
href={learnMoreLink2}
target="_blank"
sx={{ fontSize: 13, fontWeight: 500 }}
onClick={() => {
setIsOpen(false);
}}
>
<OpenInNewOutlined sx={{ fontSize: "14px !important" }} />
{learnMoreLabel2}
</MenuItem>
)}
</Menu>
</>
@@ -1,43 +0,0 @@
import { PaginationWidget } from "./PaginationWidget";
import { createPaginationRef } from "./utils";
import type { Meta, StoryObj } from "@storybook/react";
const meta: Meta<typeof PaginationWidget> = {
title: "components/PaginationWidget",
component: PaginationWidget,
args: {
prevLabel: "Previous",
nextLabel: "Next",
paginationRef: createPaginationRef({ page: 1, limit: 12 }),
numRecords: 200,
},
};
export default meta;
type Story = StoryObj<typeof PaginationWidget>;
export const MoreThan8Pages: Story = {};
export const LessThan8Pages: Story = {
args: {
numRecords: 84,
},
};
export const MoreThan7PagesWithActivePageCloseToStart: Story = {
args: {
paginationRef: createPaginationRef({ page: 2, limit: 12 }),
},
};
export const MoreThan7PagesWithActivePageFarFromBoundaries: Story = {
args: {
paginationRef: createPaginationRef({ page: 4, limit: 12 }),
},
};
export const MoreThan7PagesWithActivePageCloseToEnd: Story = {
args: {
paginationRef: createPaginationRef({ page: 17, limit: 12 }),
},
};
@@ -1,28 +0,0 @@
import { screen } from "@testing-library/react";
import { render } from "testHelpers/renderHelpers";
import { PaginationWidget } from "./PaginationWidget";
import { createPaginationRef } from "./utils";
describe("PaginatedList", () => {
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();
});
});
@@ -1,106 +0,0 @@
import Button from "@mui/material/Button";
import { makeStyles, useTheme } from "@mui/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft";
import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight";
import { useActor } from "@xstate/react";
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;
numRecords?: number;
containerStyle?: CSSProperties;
paginationRef: PaginationMachineRef;
};
export const PaginationWidget = ({
prevLabel = "",
nextLabel = "",
numRecords,
containerStyle,
paginationRef,
}: PaginationWidgetProps): JSX.Element | null => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
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;
// if beyond page 1, show pagination widget even if there's only one true page, so user can navigate back
const showWidget = numPages > 1 || currentPage > 1;
if (!showWidget) {
return null;
}
return (
<div style={containerStyle} className={styles.defaultContainerStyles}>
<Button
className={styles.prevLabelStyles}
aria-label="Previous page"
disabled={firstPageActive}
onClick={() => send({ type: "PREVIOUS_PAGE" })}
>
<KeyboardArrowLeft />
<div>{prevLabel}</div>
</Button>
{isMobile ? (
<PageButton
activePage={currentPage}
page={currentPage}
numPages={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 })}
/>
),
)
)}
<Button
aria-label="Next page"
disabled={lastPageActive}
onClick={() => send({ type: "NEXT_PAGE" })}
>
<div>{nextLabel}</div>
<KeyboardArrowRight />
</Button>
</div>
);
};
const useStyles = makeStyles((theme) => ({
defaultContainerStyles: {
justifyContent: "center",
alignItems: "center",
display: "flex",
flexDirection: "row",
padding: "20px",
},
prevLabelStyles: {
marginRight: theme.spacing(0.5),
},
}));
@@ -0,0 +1,35 @@
import { PaginationWidgetBase } from "./PaginationWidgetBase";
import type { Meta, StoryObj } from "@storybook/react";
const meta: Meta<typeof PaginationWidgetBase> = {
title: "components/PaginationWidgetBase",
component: PaginationWidgetBase,
args: {
page: 1,
limit: 12,
count: 200,
},
};
export default meta;
type Story = StoryObj<typeof PaginationWidgetBase>;
export const MoreThan8Pages: Story = {};
export const LessThan8Pages: Story = {
args: {
count: 84,
},
};
export const MoreThan7PagesWithActivePageCloseToStart: Story = {
args: { page: 2, limit: 12 },
};
export const MoreThan7PagesWithActivePageFarFromBoundaries: Story = {
args: { page: 4, limit: 12 },
};
export const MoreThan7PagesWithActivePageCloseToEnd: Story = {
args: { page: 17, limit: 12 },
};
@@ -1,31 +1,29 @@
import { buildPagedList, getOffset } from "./utils";
describe("unit/PaginationWidget", () => {
describe("buildPagedList", () => {
it.each<{
numPages: number;
activePage: number;
expected: (string | number)[];
}>([
{ numPages: 7, activePage: 1, expected: [1, 2, 3, 4, 5, 6, 7] },
{ numPages: 17, activePage: 1, expected: [1, 2, 3, 4, 5, "right", 17] },
{
numPages: 17,
activePage: 9,
expected: [1, "left", 8, 9, 10, "right", 17],
},
{
numPages: 17,
activePage: 17,
expected: [1, "left", 13, 14, 15, 16, 17],
},
])(
`buildPagedList($numPages, $activePage)`,
({ numPages, activePage, expected }) => {
expect(buildPagedList(numPages, activePage)).toEqual(expected);
},
);
});
describe("buildPagedList", () => {
it.each<{
numPages: number;
activePage: number;
expected: (string | number)[];
}>([
{ numPages: 7, activePage: 1, expected: [1, 2, 3, 4, 5, 6, 7] },
{ numPages: 17, activePage: 1, expected: [1, 2, 3, 4, 5, "right", 17] },
{
numPages: 17,
activePage: 9,
expected: [1, "left", 8, 9, 10, "right", 17],
},
{
numPages: 17,
activePage: 17,
expected: [1, "left", 13, 14, 15, 16, 17],
},
])(
`buildPagedList($numPages, $activePage)`,
({ numPages, activePage, expected }) => {
expect(buildPagedList(numPages, activePage)).toEqual(expected);
},
);
});
describe("getOffset", () => {
@@ -1,10 +1,3 @@
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:
@@ -63,46 +56,10 @@ export const buildPagedList = (
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));
};
export const nonInitialPage = (searchParams: URLSearchParams): boolean => {
const page = searchParams.get("page");
const numberPage = page ? Number(page) : 1;
+2
View File
@@ -9,6 +9,7 @@ export const usePagination = ({
const [searchParams, setSearchParams] = searchParamsResult;
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
const limit = DEFAULT_RECORDS_PER_PAGE;
const offset = page <= 0 ? 0 : (page - 1) * limit;
const goToPage = (page: number) => {
searchParams.set("page", page.toString());
@@ -19,5 +20,6 @@ export const usePagination = ({
page,
limit,
goToPage,
offset,
};
};
@@ -5,9 +5,8 @@ import { useNavigate } from "react-router-dom";
import { CreateUserForm } from "./CreateUserForm";
import { Margins } from "components/Margins/Margins";
import { pageTitle } from "utils/page";
import { getAuthMethods } from "api/api";
import { useMutation, useQuery } from "@tanstack/react-query";
import { createUser } from "api/queries/users";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { authMethods, createUser } from "api/queries/users";
import { displaySuccess } from "components/GlobalSnackbar/utils";
export const Language = {
@@ -17,13 +16,9 @@ export const Language = {
export const CreateUserPage: FC = () => {
const myOrgId = useOrganizationId();
const navigate = useNavigate();
const createUserMutation = useMutation(createUser());
// TODO: We should probably place this somewhere else to reduce the number of calls.
// This would be called each time this page is loaded.
const { data: authMethods } = useQuery({
queryKey: ["authMethods"],
queryFn: getAuthMethods,
});
const queryClient = useQueryClient();
const createUserMutation = useMutation(createUser(queryClient));
const authMethodsQuery = useQuery(authMethods());
return (
<Margins>
@@ -33,7 +28,7 @@ export const CreateUserPage: FC = () => {
<CreateUserForm
error={createUserMutation.error}
authMethods={authMethods}
authMethods={authMethodsQuery.data}
onSubmit={async (user) => {
await createUserMutation.mutateAsync(user);
displaySuccess("Successfully created user.");
@@ -3,30 +3,27 @@ import { ComponentProps, FC } from "react";
import { Section } from "components/SettingsLayout/Section";
import { SecurityForm } from "./SettingsSecurityForm";
import { useMutation, useQuery } from "@tanstack/react-query";
import { getAuthMethods, getUserLoginType } from "api/api";
import { getUserLoginType } from "api/api";
import {
SingleSignOnSection,
useSingleSignOnSection,
} from "./SingleSignOnSection";
import { Loader } from "components/Loader/Loader";
import { Stack } from "components/Stack/Stack";
import { updatePassword } from "api/queries/users";
import { authMethods, updatePassword } from "api/queries/users";
import { displaySuccess } from "components/GlobalSnackbar/utils";
export const SecurityPage: FC = () => {
const me = useMe();
const updatePasswordMutation = useMutation(updatePassword());
const { data: authMethods } = useQuery({
queryKey: ["authMethods"],
queryFn: getAuthMethods,
});
const authMethodsQuery = useQuery(authMethods());
const { data: userLoginType } = useQuery({
queryKey: ["loginType"],
queryFn: getUserLoginType,
});
const singleSignOnSection = useSingleSignOnSection();
if (!authMethods || !userLoginType) {
if (!authMethodsQuery.data || !userLoginType) {
return <Loader />;
}
@@ -51,7 +48,7 @@ export const SecurityPage: FC = () => {
}}
oidc={{
section: {
authMethods,
authMethods: authMethodsQuery.data,
userLoginType,
...singleSignOnSection,
},
+118 -194
View File
@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { rest } from "msw";
import {
@@ -6,50 +6,38 @@ import {
MockUser2,
SuspendedMockUser,
MockAuditorRole,
MockOwnerRole,
} from "testHelpers/entities";
import { Language as usersXServiceLanguage } from "xServices/users/usersXService";
import * as API from "api/api";
import { Role } from "api/typesGenerated";
import { Language as ResetPasswordDialogLanguage } from "./ResetPasswordDialog";
import { renderWithAuth } from "testHelpers/renderHelpers";
import { server } from "testHelpers/server";
import { Language as UsersPageLanguage, UsersPage } from "./UsersPage";
import { UsersPage } from "./UsersPage";
const renderPage = () => {
return renderWithAuth(<UsersPage />);
};
const suspendUser = async (setupActionSpies: () => void) => {
const suspendUser = async () => {
const user = userEvent.setup();
// Get the first user in the table
const moreButtons = await screen.findAllByLabelText("more");
const firstMoreButton = moreButtons[0];
await user.click(firstMoreButton);
const menu = await screen.findByRole("menu");
const suspendButton = within(menu).getByText(/Suspend/);
await user.click(suspendButton);
// Check if the confirm message is displayed
const confirmDialog = await screen.findByRole("dialog");
expect(confirmDialog).toHaveTextContent(
`${UsersPageLanguage.suspendDialogMessagePrefix} ${MockUser.username}?`,
);
// Setup spies to check the actions after
setupActionSpies();
// Click on the "Confirm" button
const confirmButton = await within(confirmDialog).findByText(
UsersPageLanguage.suspendDialogAction,
);
const confirmButton = await within(confirmDialog).findByRole("button", {
name: "Suspend",
});
await user.click(confirmButton);
};
const deleteUser = async (setupActionSpies: () => void) => {
const deleteUser = async () => {
const user = userEvent.setup();
// Click on the "more" button to display the "Delete" option
// Needs to await fetching users and fetching permissions, because they're needed to see the more button
@@ -73,15 +61,12 @@ const deleteUser = async (setupActionSpies: () => void) => {
const dialog = screen.getByRole("dialog");
await user.type(textField, MockUser2.username);
// Setup spies to check the actions after
setupActionSpies();
// Click on the "Confirm" button
const confirmButton = within(dialog).getByRole("button", { name: "Delete" });
await user.click(confirmButton);
};
const activateUser = async (setupActionSpies: () => void) => {
const activateUser = async () => {
const moreButtons = await screen.findAllByLabelText("more");
const suspendedMoreButton = moreButtons[2];
fireEvent.click(suspendedMoreButton);
@@ -92,17 +77,11 @@ const activateUser = async (setupActionSpies: () => void) => {
// Check if the confirm message is displayed
const confirmDialog = screen.getByRole("dialog");
expect(confirmDialog).toHaveTextContent(
`${UsersPageLanguage.activateDialogMessagePrefix} ${SuspendedMockUser.username}?`,
);
// Setup spies to check the actions after
setupActionSpies();
// Click on the "Confirm" button
const confirmButton = within(confirmDialog).getByText(
UsersPageLanguage.activateDialogAction,
);
const confirmButton = within(confirmDialog).getByRole("button", {
name: "Activate",
});
fireEvent.click(confirmButton);
};
@@ -134,7 +113,7 @@ const resetUserPassword = async (setupActionSpies: () => void) => {
fireEvent.click(confirmButton);
};
const updateUserRole = async (setupActionSpies: () => void, role: Role) => {
const updateUserRole = async (role: Role) => {
// Get the first user in the table
const users = await screen.findAllByText(/.*@coder.com/);
const userRow = users[0].closest("tr");
@@ -146,9 +125,6 @@ const updateUserRole = async (setupActionSpies: () => void, role: Role) => {
const editButton = within(userRow).getByTitle("Edit user roles");
fireEvent.click(editButton);
// Setup spies to check the actions after
setupActionSpies();
// Click on the role option
const fieldset = await screen.findByTitle("Available roles");
const auditorOption = within(fieldset).getByText(role.display_name);
@@ -162,121 +138,93 @@ const updateUserRole = async (setupActionSpies: () => void, role: Role) => {
describe("UsersPage", () => {
describe("suspend user", () => {
describe("when it is success", () => {
it("shows a success message and refresh the page", async () => {
it("shows a success message", async () => {
renderPage();
await suspendUser(() => {
jest.spyOn(API, "suspendUser").mockResolvedValueOnce(MockUser);
jest.spyOn(API, "getUsers").mockResolvedValueOnce({
users: [SuspendedMockUser, MockUser2],
count: 2,
});
});
server.use(
rest.put(
`/api/v2/users/${MockUser.id}/status/suspend`,
async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(SuspendedMockUser));
},
),
);
await suspendUser();
// Check if the success message is displayed
await screen.findByText(usersXServiceLanguage.suspendUserSuccess);
// Check if the API was called correctly
expect(API.suspendUser).toBeCalledTimes(1);
expect(API.suspendUser).toBeCalledWith(MockUser.id);
// Check if the users list was reload
await waitFor(() => expect(API.getUsers).toBeCalledTimes(1));
await screen.findByText("Successfully suspended the user.");
});
});
describe("when it fails", () => {
it("shows an error message", async () => {
renderPage();
await suspendUser(() => {
jest.spyOn(API, "suspendUser").mockRejectedValueOnce({});
});
server.use(
rest.put(
`/api/v2/users/${MockUser.id}/status/suspend`,
async (req, res, ctx) => {
return res(
ctx.status(400),
ctx.json({
message: "Error suspending user.",
}),
);
},
),
);
await suspendUser();
// Check if the error message is displayed
await screen.findByText(usersXServiceLanguage.suspendUserError);
// Check if the API was called correctly
expect(API.suspendUser).toBeCalledTimes(1);
expect(API.suspendUser).toBeCalledWith(MockUser.id);
await screen.findByText("Error suspending user.");
});
});
});
describe("pagination", () => {
it("goes to next and previous page", async () => {
const { container } = renderPage();
const user = userEvent.setup();
const mock = jest
.spyOn(API, "getUsers")
.mockResolvedValueOnce({ users: [MockUser, MockUser2], count: 26 });
const nextButton = await screen.findByLabelText("Next page");
expect(nextButton).toBeEnabled();
const previousButton = await screen.findByLabelText("Previous page");
expect(previousButton).toBeDisabled();
await user.click(nextButton);
await waitFor(() =>
expect(API.getUsers).toBeCalledWith({ offset: 25, limit: 25, q: "" }),
);
mock.mockClear();
await user.click(previousButton);
await waitFor(() =>
expect(API.getUsers).toBeCalledWith({ offset: 0, limit: 25, q: "" }),
);
const pageButtons = container.querySelectorAll(
`button[name="Page button"]`,
);
// count handler says there are 2 pages of results
expect(pageButtons.length).toBe(2);
});
});
describe("delete user", () => {
describe("when it is success", () => {
it("shows a success message and refresh the page", async () => {
it("shows a success message", async () => {
renderPage();
await deleteUser(() => {
jest.spyOn(API, "deleteUser").mockResolvedValueOnce(undefined);
jest.spyOn(API, "getUsers").mockResolvedValueOnce({
users: [MockUser, SuspendedMockUser],
count: 2,
});
});
server.use(
rest.delete(
`/api/v2/users/${MockUser2.id}`,
async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockUser2));
},
),
);
await deleteUser();
// Check if the success message is displayed
await screen.findByText(usersXServiceLanguage.deleteUserSuccess);
// Check if the API was called correctly
expect(API.deleteUser).toBeCalledTimes(1);
expect(API.deleteUser).toBeCalledWith(MockUser2.id);
// Check if the users list was reloaded
await waitFor(() => {
const users = screen.getAllByLabelText("more");
expect(users.length).toEqual(2);
});
await screen.findByText("Successfully deleted the user.");
});
});
describe("when it fails", () => {
it("shows an error message", async () => {
renderPage();
await deleteUser(() => {
jest.spyOn(API, "deleteUser").mockRejectedValueOnce({});
});
server.use(
rest.delete(
`/api/v2/users/${MockUser2.id}`,
async (req, res, ctx) => {
return res(
ctx.status(400),
ctx.json({
message: "Error deleting user.",
}),
);
},
),
);
await deleteUser();
// Check if the error message is displayed
await screen.findByText(usersXServiceLanguage.deleteUserError);
// Check if the API was called correctly
expect(API.deleteUser).toBeCalledTimes(1);
expect(API.deleteUser).toBeCalledWith(MockUser2.id);
await screen.findByText("Error deleting user.");
});
});
});
@@ -286,40 +234,43 @@ describe("UsersPage", () => {
it("shows a success message and refreshes the page", async () => {
renderPage();
await activateUser(() => {
jest
.spyOn(API, "activateUser")
.mockResolvedValueOnce(SuspendedMockUser);
jest.spyOn(API, "getUsers").mockImplementationOnce(() =>
Promise.resolve({
users: [MockUser, MockUser2, SuspendedMockUser],
count: 3,
}),
);
});
server.use(
rest.put(
`/api/v2/users/${SuspendedMockUser.id}/status/activate`,
async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockUser));
},
),
);
await activateUser();
// Check if the success message is displayed
await screen.findByText(usersXServiceLanguage.activateUserSuccess);
// Check if the API was called correctly
expect(API.activateUser).toBeCalledTimes(1);
expect(API.activateUser).toBeCalledWith(SuspendedMockUser.id);
await screen.findByText("Successfully activated the user.");
});
});
describe("when activation fails", () => {
it("shows an error message", async () => {
renderPage();
await activateUser(() => {
jest.spyOn(API, "activateUser").mockRejectedValueOnce({});
});
server.use(
rest.put(
`/api/v2/users/${SuspendedMockUser.id}/status/activate`,
async (req, res, ctx) => {
return res(
ctx.status(400),
ctx.json({
message: "Error activating user.",
}),
);
},
),
);
await activateUser();
// Check if the error message is displayed
await screen.findByText(usersXServiceLanguage.activateUserError);
// Check if the API was called correctly
expect(API.activateUser).toBeCalledTimes(1);
expect(API.activateUser).toBeCalledWith(SuspendedMockUser.id);
await screen.findByText("Error activating user.");
});
});
});
@@ -336,7 +287,7 @@ describe("UsersPage", () => {
});
// Check if the success message is displayed
await screen.findByText(usersXServiceLanguage.resetUserPasswordSuccess);
await screen.findByText("Successfully updated the user password.");
// Check if the API was called correctly
expect(API.updateUserPassword).toBeCalledTimes(1);
@@ -355,7 +306,7 @@ describe("UsersPage", () => {
});
// Check if the error message is displayed
await screen.findByText(usersXServiceLanguage.resetUserPasswordError);
await screen.findByText("Error on resetting the user password.");
// Check if the API was called correctly
expect(API.updateUserPassword).toBeCalledTimes(1);
@@ -372,26 +323,24 @@ describe("UsersPage", () => {
it("updates the roles", async () => {
renderPage();
const { userRow } = await updateUserRole(() => {
jest.spyOn(API, "updateUserRoles").mockResolvedValueOnce({
...MockUser,
roles: [...MockUser.roles, MockAuditorRole],
});
}, MockAuditorRole);
// Check if the select text was updated with the Auditor role
await waitFor(() => {
expect(userRow).toHaveTextContent(MockOwnerRole.display_name);
});
expect(userRow).toHaveTextContent(MockAuditorRole.display_name);
// Check if the API was called correctly
const currentRoles = MockUser.roles.map((r) => r.name);
expect(API.updateUserRoles).toBeCalledTimes(1);
expect(API.updateUserRoles).toBeCalledWith(
[...currentRoles, MockAuditorRole.name],
MockUser.id,
server.use(
rest.put(
`/api/v2/users/${MockUser.id}/roles`,
async (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
...MockUser,
roles: [...MockUser.roles, MockAuditorRole],
}),
);
},
),
);
await updateUserRole(MockAuditorRole);
await screen.findByText("Successfully updated the user roles.");
});
});
@@ -399,44 +348,19 @@ describe("UsersPage", () => {
it("shows an error message", async () => {
renderPage();
await updateUserRole(() => {
jest.spyOn(API, "updateUserRoles").mockRejectedValueOnce({});
}, MockAuditorRole);
// Check if the error message is displayed
const errorMessage = await screen.findByText(
usersXServiceLanguage.updateUserRolesError,
);
await waitFor(() => expect(errorMessage).toBeDefined());
// Check if the API was called correctly
const currentRoles = MockUser.roles.map((r) => r.name);
expect(API.updateUserRoles).toBeCalledTimes(1);
expect(API.updateUserRoles).toBeCalledWith(
[...currentRoles, MockAuditorRole.name],
MockUser.id,
);
});
it("shows an error from the backend", async () => {
renderPage();
server.use(
rest.put(`/api/v2/users/${MockUser.id}/roles`, (req, res, ctx) => {
return res(
ctx.status(400),
ctx.json({ message: "message from the backend" }),
ctx.json({ message: "Error on updating the user roles." }),
);
}),
);
await updateUserRole(() => null, MockAuditorRole);
await updateUserRole(MockAuditorRole);
// Check if the error message is displayed
const errorMessage = await screen.findByText(
"message from the backend",
);
expect(errorMessage).toBeDefined();
await screen.findByText("Error on updating the user roles.");
});
});
});
+142 -144
View File
@@ -1,16 +1,11 @@
import { useMachine } from "@xstate/react";
import { User } from "api/typesGenerated";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import {
getPaginationContext,
nonInitialPage,
} from "components/PaginationWidget/utils";
import { nonInitialPage } from "components/PaginationWidget/utils";
import { useMe } from "hooks/useMe";
import { usePermissions } from "hooks/usePermissions";
import { FC, ReactNode, useEffect } from "react";
import { FC, ReactNode, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useSearchParams, useNavigate } from "react-router-dom";
import { usersMachine } from "xServices/users/usersXService";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { ResetPasswordDialog } from "./ResetPasswordDialog";
import { pageTitle } from "utils/page";
@@ -18,50 +13,41 @@ import { UsersPageView } from "./UsersPageView";
import { useStatusFilterMenu } from "./UsersFilter";
import { useFilter } from "components/Filter/filter";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { useQuery } from "@tanstack/react-query";
import { getAuthMethods } from "api/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { roles } from "api/queries/roles";
import { deploymentConfig } from "api/queries/deployment";
export const Language = {
suspendDialogTitle: "Suspend user",
suspendDialogAction: "Suspend",
suspendDialogMessagePrefix: "Do you want to suspend the user",
activateDialogTitle: "Activate user",
activateDialogAction: "Activate",
activateDialogMessagePrefix: "Do you want to activate the user",
};
const getSelectedUser = (id: string, users?: User[]) =>
users?.find((u) => u.id === id);
import { prepareQuery } from "utils/filters";
import { usePagination } from "hooks";
import {
users,
suspendUser,
activateUser,
deleteUser,
updatePassword,
updateRoles,
authMethods,
} from "api/queries/users";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { getErrorMessage } from "api/errors";
import { generateRandomString } from "utils/random";
export const UsersPage: FC<{ children?: ReactNode }> = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const searchParamsResult = useSearchParams();
const { entitlements } = useDashboard();
const [searchParams, setSearchParams] = searchParamsResult;
const [searchParams] = searchParamsResult;
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 pagination = usePagination({
searchParamsResult,
});
const {
users,
getUsersError,
usernameToDelete,
usernameToSuspend,
usernameToActivate,
userIdToResetPassword,
newUserPassword,
paginationRef,
count,
} = usersState.context;
const usersQuery = useQuery(
users({
q: prepareQuery(filter),
limit: pagination.limit,
offset: pagination.offset,
}),
);
const { updateUsers: canEditUsers, viewDeploymentValues } = usePermissions();
const rolesQuery = useQuery({ ...roles(), enabled: canEditUsers });
const { data: deploymentValues } = useQuery({
@@ -77,12 +63,9 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
const useFilterResult = useFilter({
searchParamsResult,
onUpdate: () => {
usersSend({ type: "UPDATE_PAGE", page: "1" });
pagination.goToPage(1);
},
});
useEffect(() => {
usersSend({ type: "UPDATE_FILTER", query: useFilterResult.query });
}, [useFilterResult.query, usersSend]);
const statusMenu = useStatusFilterMenu({
value: useFilterResult.values.status,
onChange: (option) =>
@@ -91,19 +74,26 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
status: option?.value,
}),
});
const authMethods = useQuery({
queryKey: ["authMethods"],
queryFn: () => {
return getAuthMethods();
},
});
// Is loading if
// - users are loading or
// - the user can edit the users but the roles are loading
const authMethodsQuery = useQuery(authMethods());
const isLoading =
usersState.matches("gettingUsers") ||
rolesQuery.isLoading ||
authMethods.isLoading;
usersQuery.isLoading || rolesQuery.isLoading || authMethodsQuery.isLoading;
const [confirmSuspendUser, setConfirmSuspendUser] = useState<User>();
const suspendUserMutation = useMutation(suspendUser(queryClient));
const [confirmActivateUser, setConfirmActivateUser] = useState<User>();
const activateUserMutation = useMutation(activateUser(queryClient));
const [confirmDeleteUser, setConfirmDeleteUser] = useState<User>();
const deleteUserMutation = useMutation(deleteUser(queryClient));
const [confirmResetPassword, setConfirmResetPassword] = useState<{
user: User;
newPassword: string;
}>();
const updatePasswordMutation = useMutation(updatePassword());
const updateRolesMutation = useMutation(updateRoles(queryClient));
return (
<>
@@ -113,9 +103,8 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
<UsersPageView
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
roles={rolesQuery.data}
users={users}
authMethods={authMethods.data}
count={count}
users={usersQuery.data?.users}
authMethods={authMethodsQuery.data}
onListWorkspaces={(user) => {
navigate(
"/workspaces?filter=" +
@@ -127,91 +116,90 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
"/audit?filter=" + encodeURIComponent(`username:${user.username}`),
);
}}
onDeleteUser={(user) => {
usersSend({
type: "DELETE_USER",
userId: user.id,
username: user.username,
});
}}
onSuspendUser={(user) => {
usersSend({
type: "SUSPEND_USER",
userId: user.id,
username: user.username,
});
}}
onActivateUser={(user) => {
usersSend({
type: "ACTIVATE_USER",
userId: user.id,
username: user.username,
});
}}
onDeleteUser={setConfirmDeleteUser}
onSuspendUser={setConfirmSuspendUser}
onActivateUser={setConfirmActivateUser}
onResetUserPassword={(user) => {
usersSend({ type: "RESET_USER_PASSWORD", userId: user.id });
}}
onUpdateUserRoles={(user, roles) => {
usersSend({
type: "UPDATE_USER_ROLES",
userId: user.id,
roles,
setConfirmResetPassword({
user,
newPassword: generateRandomString(12),
});
}}
isUpdatingUserRoles={usersState.matches("updatingUserRoles")}
onUpdateUserRoles={async (user, roles) => {
try {
await updateRolesMutation.mutateAsync({
userId: user.id,
roles,
});
displaySuccess("Successfully updated the user roles.");
} catch (e) {
displayError(
getErrorMessage(e, "Error on updating the user roles."),
);
}
}}
isUpdatingUserRoles={updateRolesMutation.isLoading}
isLoading={isLoading}
canEditUsers={canEditUsers}
canViewActivity={entitlements.features.audit_log.enabled}
paginationRef={paginationRef}
isNonInitialPage={nonInitialPage(searchParams)}
actorID={me.id}
filterProps={{
filter: useFilterResult,
error: getUsersError,
error: usersQuery.error,
menus: {
status: statusMenu,
},
}}
count={usersQuery.data?.count}
page={pagination.page}
limit={pagination.limit}
onPageChange={pagination.goToPage}
/>
<DeleteDialog
key={usernameToDelete}
isOpen={
usersState.matches("confirmUserDeletion") ||
usersState.matches("deletingUser")
}
confirmLoading={usersState.matches("deletingUser")}
name={usernameToDelete ?? ""}
key={confirmDeleteUser?.username}
isOpen={confirmDeleteUser !== undefined}
confirmLoading={deleteUserMutation.isLoading}
name={confirmDeleteUser?.username ?? ""}
entity="user"
onConfirm={() => {
usersSend("CONFIRM_USER_DELETE");
onConfirm={async () => {
try {
await deleteUserMutation.mutateAsync(confirmDeleteUser!.id);
setConfirmDeleteUser(undefined);
displaySuccess("Successfully deleted the user.");
} catch (e) {
displayError(getErrorMessage(e, "Error deleting user."));
}
}}
onCancel={() => {
usersSend("CANCEL_USER_DELETE");
setConfirmDeleteUser(undefined);
}}
/>
<ConfirmDialog
type="delete"
hideCancel={false}
open={
usersState.matches("confirmUserSuspension") ||
usersState.matches("suspendingUser")
}
confirmLoading={usersState.matches("suspendingUser")}
title={Language.suspendDialogTitle}
confirmText={Language.suspendDialogAction}
onConfirm={() => {
usersSend("CONFIRM_USER_SUSPENSION");
open={confirmSuspendUser !== undefined}
confirmLoading={suspendUserMutation.isLoading}
title="Suspend user"
confirmText="Suspend"
onConfirm={async () => {
try {
await suspendUserMutation.mutateAsync(confirmSuspendUser!.id);
setConfirmSuspendUser(undefined);
displaySuccess("Successfully suspended the user.");
} catch (e) {
displayError(getErrorMessage(e, "Error suspending user."));
}
}}
onClose={() => {
usersSend("CANCEL_USER_SUSPENSION");
setConfirmSuspendUser(undefined);
}}
description={
<>
{Language.suspendDialogMessagePrefix}
{usernameToSuspend && " "}
<strong>{usernameToSuspend ?? ""}</strong>?
Do you want to suspend the user{" "}
<strong>{confirmSuspendUser?.username ?? ""}</strong>?
</>
}
/>
@@ -219,45 +207,55 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
<ConfirmDialog
type="success"
hideCancel={false}
open={
usersState.matches("confirmUserActivation") ||
usersState.matches("activatingUser")
}
confirmLoading={usersState.matches("activatingUser")}
title={Language.activateDialogTitle}
confirmText={Language.activateDialogAction}
onConfirm={() => {
usersSend("CONFIRM_USER_ACTIVATION");
open={confirmActivateUser !== undefined}
confirmLoading={activateUserMutation.isLoading}
title="Activate user"
confirmText="Activate"
onConfirm={async () => {
try {
await activateUserMutation.mutateAsync(confirmActivateUser!.id);
setConfirmActivateUser(undefined);
displaySuccess("Successfully activated the user.");
} catch (e) {
displayError(getErrorMessage(e, "Error activating user."));
}
}}
onClose={() => {
usersSend("CANCEL_USER_ACTIVATION");
setConfirmActivateUser(undefined);
}}
description={
<>
{Language.activateDialogMessagePrefix}
{usernameToActivate && " "}
<strong>{usernameToActivate ?? ""}</strong>?
Do you want to activate{" "}
<strong>{confirmActivateUser?.username ?? ""}</strong>?
</>
}
/>
{userIdToResetPassword && (
<ResetPasswordDialog
open={
usersState.matches("confirmUserPasswordReset") ||
usersState.matches("resettingUserPassword")
<ResetPasswordDialog
key={confirmResetPassword?.user.username}
open={confirmResetPassword !== undefined}
loading={updatePasswordMutation.isLoading}
user={confirmResetPassword?.user}
newPassword={confirmResetPassword?.newPassword}
onClose={() => {
setConfirmResetPassword(undefined);
}}
onConfirm={async () => {
try {
await updatePasswordMutation.mutateAsync({
userId: confirmResetPassword!.user.id,
password: confirmResetPassword!.newPassword,
old_password: "",
});
setConfirmResetPassword(undefined);
displaySuccess("Successfully updated the user password.");
} catch (e) {
displayError(
getErrorMessage(e, "Error on resetting the user password."),
);
}
loading={usersState.matches("resettingUserPassword")}
user={getSelectedUser(userIdToResetPassword, users)}
newPassword={newUserPassword}
onClose={() => {
usersSend("CANCEL_USER_PASSWORD_RESET");
}}
onConfirm={() => {
usersSend("CONFIRM_USER_PASSWORD_RESET");
}}
/>
)}
}}
/>
</>
);
};
@@ -1,5 +1,4 @@
import { Meta, StoryObj } from "@storybook/react";
import { createPaginationRef } from "components/PaginationWidget/utils";
import {
MockUser,
MockUser2,
@@ -30,7 +29,8 @@ const meta: Meta<typeof UsersPageView> = {
title: "pages/UsersPageView",
component: UsersPageView,
args: {
paginationRef: createPaginationRef({ page: 1, limit: 25 }),
page: 1,
limit: 25,
isNonInitialPage: false,
users: [MockUser, MockUser2],
roles: MockAssignableSiteRoles,
+18 -7
View File
@@ -1,6 +1,4 @@
import { PaginationWidget } from "components/PaginationWidget/PaginationWidget";
import { ComponentProps, FC } from "react";
import { PaginationMachineRef } from "xServices/pagination/paginationXService";
import * as TypesGen from "api/typesGenerated";
import { UsersTable } from "./UsersTable/UsersTable";
import { UsersFilter } from "./UsersFilter";
@@ -8,10 +6,10 @@ import {
PaginationStatus,
TableToolbar,
} from "components/TableToolbar/TableToolbar";
import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase";
export interface UsersPageViewProps {
users?: TypesGen.User[];
count?: number;
roles?: TypesGen.AssignableRoles[];
isUpdatingUserRoles?: boolean;
canEditUsers?: boolean;
@@ -30,14 +28,17 @@ export interface UsersPageViewProps {
roles: TypesGen.Role["name"][],
) => void;
filterProps: ComponentProps<typeof UsersFilter>;
paginationRef: PaginationMachineRef;
isNonInitialPage: boolean;
actorID: string;
// Pagination
count?: number;
page: number;
limit: number;
onPageChange: (page: number) => void;
}
export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
users,
count,
roles,
onSuspendUser,
onDeleteUser,
@@ -52,10 +53,13 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
canViewActivity,
isLoading,
filterProps,
paginationRef,
isNonInitialPage,
actorID,
authMethods,
count,
limit,
onPageChange,
page,
}) => {
return (
<>
@@ -90,7 +94,14 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
authMethods={authMethods}
/>
<PaginationWidget numRecords={count} paginationRef={paginationRef} />
{count && (
<PaginationWidgetBase
count={count}
limit={limit}
onChange={onPageChange}
page={page}
/>
)}
</>
);
};
@@ -188,7 +188,7 @@ const useStyles = makeStyles((theme) => ({
padding: 0,
"&:disabled": {
opacity: 0,
opacity: 0.5,
},
},
options: {
+4
View File
@@ -341,6 +341,10 @@ export const handlers = [
return res(ctx.status(200), ctx.json(M.MockDeploymentStats));
}),
rest.get("/api/v2/deployment/config", (_, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockDeploymentConfig));
}),
rest.get(
"/api/v2/workspacebuilds/:workspaceBuildId/parameters",
(_, res, ctx) => {
+5 -2
View File
@@ -3,12 +3,15 @@ import * as TypesGen from "api/typesGenerated";
export const queryToFilter = (
query?: string,
): TypesGen.WorkspaceFilter | TypesGen.UsersRequest => {
const preparedQuery = query?.trim().replace(/ +/g, " ");
return {
q: preparedQuery,
q: prepareQuery(query),
};
};
export const prepareQuery = (query?: string) => {
return query?.trim().replace(/ +/g, " ");
};
export const workspaceFilterQuery = {
me: "owner:me",
all: "",
@@ -1,66 +0,0 @@
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,
}),
},
},
);
-554
View File
@@ -1,554 +0,0 @@
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";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { queryToFilter } from "utils/filters";
import { generateRandomString } from "utils/random";
const usersPaginationId = "usersPagination";
export const Language = {
getUsersError: "Error getting users.",
suspendUserSuccess: "Successfully suspended the user.",
suspendUserError: "Error suspending user.",
deleteUserSuccess: "Successfully deleted the user.",
deleteUserError: "Error deleting user.",
activateUserSuccess: "Successfully activated the user.",
activateUserError: "Error activating user.",
resetUserPasswordSuccess: "Successfully updated the user password.",
resetUserPasswordError: "Error on resetting the user password.",
updateUserRolesSuccess: "Successfully updated the user roles.",
updateUserRolesError: "Error on updating the user roles.",
};
export interface UsersContext {
// Get users
users?: TypesGen.User[];
filter: string;
getUsersError?: unknown;
// Suspend user
userIdToSuspend?: TypesGen.User["id"];
usernameToSuspend?: TypesGen.User["username"];
suspendUserError?: unknown;
// Delete user
userIdToDelete?: TypesGen.User["id"];
usernameToDelete?: TypesGen.User["username"];
deleteUserError?: unknown;
// Activate user
userIdToActivate?: TypesGen.User["id"];
usernameToActivate?: TypesGen.User["username"];
activateUserError?: unknown;
// Reset user password
userIdToResetPassword?: TypesGen.User["id"];
resetUserPasswordError?: unknown;
newUserPassword?: string;
// Update user roles
userIdToUpdateRoles?: TypesGen.User["id"];
updateUserRolesError?: unknown;
paginationContext: PaginationContext;
paginationRef: PaginationMachineRef;
count: number;
}
export type UsersEvent =
| { type: "GET_USERS"; query?: string }
// Suspend events
| {
type: "SUSPEND_USER";
userId: TypesGen.User["id"];
username: TypesGen.User["username"];
}
| { type: "CONFIRM_USER_SUSPENSION" }
| { type: "CANCEL_USER_SUSPENSION" }
// Delete events
| {
type: "DELETE_USER";
userId: TypesGen.User["id"];
username: TypesGen.User["username"];
}
| { type: "CONFIRM_USER_DELETE" }
| { type: "CANCEL_USER_DELETE" }
// Activate events
| {
type: "ACTIVATE_USER";
userId: TypesGen.User["id"];
username: TypesGen.User["username"];
}
| { type: "CONFIRM_USER_ACTIVATION" }
| { type: "CANCEL_USER_ACTIVATION" }
// Reset password events
| { type: "RESET_USER_PASSWORD"; userId: TypesGen.User["id"] }
| { type: "CONFIRM_USER_PASSWORD_RESET" }
| { type: "CANCEL_USER_PASSWORD_RESET" }
// Update roles events
| {
type: "UPDATE_USER_ROLES";
userId: TypesGen.User["id"];
roles: TypesGen.Role["name"][];
}
// Filter
| { type: "UPDATE_FILTER"; query: string }
// Pagination
| { type: "UPDATE_PAGE"; page: string };
export const usersMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QFdZgE6wMoBcCGOYAdLPujgJYB2UACnlNQRQPZUDEA2gAwC6ioAA4tYFSmwEgAHogC0ARgBMigBxEAnCoCsAdhWrdK9QBYdAGhABPRPO7ruRAMzdj3HYuNbFANm5b1AL4BFqgY2PiERDA4lDQAqmiY7BBsxNQAbiwA1sTRCWE8-EggwqLiVJIyCArc8upOxt6K-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-j-ITHPcS7DXhWc1XMTT-qATGa4jxDoK5kxWjeL0mqIJ5NczwUEIAA */
createMachine(
{
tsTypes: {} as import("./usersXService.typegen").Typegen0,
schema: {
context: {} as UsersContext,
events: {} as UsersEvent,
services: {} as {
getUsers: {
data: TypesGen.GetUsersResponse;
};
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",
on: {
UPDATE_FILTER: {
actions: ["assignFilter", "sendResetPage"],
},
UPDATE_PAGE: {
target: "gettingUsers",
actions: "updateURL",
},
},
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"],
},
],
},
tags: "loading",
},
idle: {
entry: "clearSelectedUser",
on: {
SUSPEND_USER: {
target: "confirmUserSuspension",
actions: "assignUserToSuspend",
},
DELETE_USER: {
target: "confirmUserDeletion",
actions: "assignUserToDelete",
},
ACTIVATE_USER: {
target: "confirmUserActivation",
actions: "assignUserToActivate",
},
RESET_USER_PASSWORD: {
target: "confirmUserPasswordReset",
actions: [
"assignUserIdToResetPassword",
"generateRandomPassword",
],
},
UPDATE_USER_ROLES: {
target: "updatingUserRoles",
actions: "assignUserIdToUpdateRoles",
},
},
},
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: {
clearSelectedUser: assign({
userIdToSuspend: (_) => undefined,
usernameToSuspend: (_) => undefined,
userIdToDelete: (_) => undefined,
usernameToDelete: (_) => undefined,
userIdToActivate: (_) => undefined,
usernameToActivate: (_) => undefined,
userIdToResetPassword: (_) => undefined,
userIdToUpdateRoles: (_) => undefined,
}),
assignUsers: assign({
users: (_, event) => event.data.users,
count: (_, event) => event.data.count,
}),
assignFilter: assign({
filter: (_, event) => event.query,
}),
assignGetUsersError: assign({
getUsersError: (_, event) => event.data,
}),
assignUserToSuspend: assign({
userIdToSuspend: (_, event) => event.userId,
usernameToSuspend: (_, event) => event.username,
}),
assignUserToDelete: assign({
userIdToDelete: (_, event) => event.userId,
usernameToDelete: (_, event) => event.username,
}),
assignUserToActivate: assign({
userIdToActivate: (_, event) => event.userId,
usernameToActivate: (_, event) => event.username,
}),
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,
count: undefined,
})),
clearSuspendUserError: assign({
suspendUserError: (_) => undefined,
}),
clearDeleteUserError: assign({
deleteUserError: (_) => undefined,
}),
clearActivateUserError: assign({
activateUserError: (_) => undefined,
}),
clearResetUserPasswordError: assign({
resetUserPasswordError: (_) => undefined,
}),
clearUpdateUserRolesError: assign({
updateUserRolesError: (_) => undefined,
}),
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 }),
},
},
);