Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Edit user form #2566

Merged
merged 4 commits into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion assets/js/lib/api/users.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { del, get, post } from '@lib/network';
import { del, get, patch, post } from '@lib/network';

export const listUsers = () => get('/users');

export const getUser = (userID) => get(`/users/${userID}`);

export const createUser = (payload) => post('/users', payload);

export const editUser = (userID, payload) => patch(`/users/${userID}`, payload);

export const deleteUser = (userID) => del(`/users/${userID}`);
4 changes: 2 additions & 2 deletions assets/js/lib/test-utils/factories/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import { formatISO } from 'date-fns';
export const userFactory = Factory.define(() => ({
id: faker.number.int(),
username: faker.internet.userName(),
created_at: formatISO(faker.date.past()),
actions: 'Delete',
enabled: faker.datatype.boolean(),
fullname: faker.internet.displayName(),
email: faker.internet.email(),
password: faker.internet.password(),
arbulu89 marked this conversation as resolved.
Show resolved Hide resolved
created_at: formatISO(faker.date.past()),
updated_at: formatISO(faker.date.past()),
}));

export const adminUser = userFactory.params({
Expand Down
7 changes: 5 additions & 2 deletions assets/js/pages/Users/CreateUserPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import '@testing-library/jest-dom';

import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import { faker } from '@faker-js/faker';

// eslint-disable-next-line import/no-extraneous-dependencies
import * as router from 'react-router';
Expand Down Expand Up @@ -53,7 +54,8 @@ describe('CreateUserPage', () => {
const user = userEvent.setup();
const navigate = jest.fn();
jest.spyOn(router, 'useNavigate').mockImplementation(() => navigate);
const { fullname, email, username, password } = userFactory.build();
const { fullname, email, username } = userFactory.build();
const password = faker.internet.password();

axiosMock.onPost(USERS_URL).reply(202, {});

Expand All @@ -74,7 +76,8 @@ describe('CreateUserPage', () => {
const user = userEvent.setup();
const navigate = jest.fn();
jest.spyOn(router, 'useNavigate').mockImplementation(() => navigate);
const { fullname, email, username, password } = userFactory.build();
const { fullname, email, username } = userFactory.build();
const password = faker.internet.password();

const errors = [
{
Expand Down
109 changes: 109 additions & 0 deletions assets/js/pages/Users/EditUserPage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';

import BackButton from '@common/BackButton';
import Banner from '@common/Banners/Banner';
import PageHeader from '@common/PageHeader';

import { editUser, getUser } from '@lib/api/users';

import UserForm from './UserForm';

function EditUserPage() {
const { userID } = useParams();
const navigate = useNavigate();
const [savingState, setSaving] = useState(false);
const [errorsState, setErrors] = useState([]);
const [loading, setLoading] = useState(true);
const [userState, setUser] = useState(null);
const [updatedByOther, setUpdatedByOther] = useState(false);

useEffect(() => {
getUser(userID)
.then(({ data: user }) => {
setUser(user);
})
.catch(() => {})
.finally(() => {
setLoading(false);
});
}, [userID]);

const onEditUser = (payload) => {
setSaving(true);
editUser(userID, payload)
.then(() => {
navigate('/users');
})
.catch(
({
response: {
status,
data: { errors },
},
}) => {
if (status === 412) {
setUpdatedByOther(true);
return;
}
setErrors(errors);
}
)
.finally(() => {
setSaving(false);
});
};

const onCancel = () => {
navigate('/users');
};

if (loading) {
return <div>Loading...</div>;
}

if (!userState) {
return <div>Not found</div>;
}

const {
fullname,
email,
username,
enabled,
created_at: createdAt,
updated_at: updatedAt,
} = userState;

return (
<div>
<BackButton url="/users">Back to Users</BackButton>
{updatedByOther && (
<Banner type="warning">
<span className="text-sm">
Information has been updated by another user and your changes have
not been saved. Please refresh the page to load the latest of
information.
</span>
</Banner>
)}
<PageHeader className="font-bold">Edit User</PageHeader>
<UserForm
saveText="Edit"
fullName={fullname}
emailAddress={email}
username={username}
status={enabled ? 'Enabled' : 'Disabled'}
createdAt={createdAt}
updatedAt={updatedAt}
saving={savingState}
errors={errorsState}
onSave={onEditUser}
onCancel={onCancel}
editing
/>
</div>
);
}

export default EditUserPage;
156 changes: 156 additions & 0 deletions assets/js/pages/Users/EditUserPage.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import React from 'react';

import { screen } from '@testing-library/react';
import 'intersection-observer';
import '@testing-library/jest-dom';

import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';

// eslint-disable-next-line import/no-extraneous-dependencies
import * as router from 'react-router';

import { networkClient } from '@lib/network';

import { renderWithRouterMatch } from '@lib/test-utils';
import { userFactory } from '@lib/test-utils/factories/users';

import EditUserPage from './EditUserPage';

const USERS_URL = '/api/v1/users/';
const axiosMock = new MockAdapter(networkClient);

describe('EditUserPage', () => {
beforeEach(() => {
axiosMock.reset();
jest.spyOn(console, 'error').mockImplementation(() => null);
});

it('should redirect back to users when the Back To Users button is clicked', async () => {
const user = userEvent.setup();
const navigate = jest.fn();
jest.spyOn(router, 'useNavigate').mockImplementation(() => navigate);
const userData = userFactory.build();
axiosMock.onGet(USERS_URL.concat(userData.id)).reply(200, userData);

renderWithRouterMatch(<EditUserPage />, {
path: '/users/:userID/edit',
route: `/users/${userData.id}/edit`,
});

await screen.findByText('Edit User');

await user.click(screen.getByRole('button', { name: 'Back to Users' }));

expect(navigate).toHaveBeenCalledWith('/users');
});

it('should redirect back to users when the Cancel button is clicked', async () => {
const user = userEvent.setup();
const navigate = jest.fn();
jest.spyOn(router, 'useNavigate').mockImplementation(() => navigate);
const userData = userFactory.build();
axiosMock.onGet(USERS_URL.concat(userData.id)).reply(200, userData);

renderWithRouterMatch(<EditUserPage />, {
path: '/users/:userID/edit',
route: `/users/${userData.id}/edit`,
});

await screen.findByText('Edit User');

await user.click(screen.getByRole('button', { name: 'Cancel' }));

expect(navigate).toHaveBeenCalledWith('/users');
});

it('should show user not found if the given user ID does not exist', async () => {
const userID = '1';
axiosMock.onGet(USERS_URL.concat(userID)).reply(404, {});

renderWithRouterMatch(<EditUserPage />, {
path: '/users/:userID/edit',
route: `/users/${userID}/edit`,
});

await screen.findByText('Not found');
});

it('should edit a user and redirect to users view', async () => {
const user = userEvent.setup();
const navigate = jest.fn();
jest.spyOn(router, 'useNavigate').mockImplementation(() => navigate);
const userData = userFactory.build();

axiosMock
.onGet(USERS_URL.concat(userData.id))
.reply(200, userData)
.onPatch(USERS_URL.concat(userData.id))
.reply(204, {});

renderWithRouterMatch(<EditUserPage />, {
path: '/users/:userID/edit',
route: `/users/${userData.id}/edit`,
});

await screen.findByText('Edit User');

await user.click(screen.getByRole('button', { name: 'Edit' }));

expect(navigate).toHaveBeenCalledWith('/users');
});

it('should display validation errors', async () => {
const user = userEvent.setup();
const userData = userFactory.build();

const errors = [
{
detail: 'Error validating fullname',
source: { pointer: '/fullname' },
title: 'Invalid value',
},
];

axiosMock
.onGet(USERS_URL.concat(userData.id))
.reply(200, userData)
.onPatch(USERS_URL.concat(userData.id))
.reply(422, { errors });

renderWithRouterMatch(<EditUserPage />, {
path: '/users/:userID/edit',
route: `/users/${userData.id}/edit`,
});

await screen.findByText('Edit User');

await user.click(screen.getByRole('button', { name: 'Edit' }));

await screen.findByText('Error validating fullname');
});

it('should display user already updated warning banner', async () => {
const user = userEvent.setup();
const userData = userFactory.build();

axiosMock
.onGet(USERS_URL.concat(userData.id))
.reply(200, userData)
.onPatch(USERS_URL.concat(userData.id))
.reply(412, {});

renderWithRouterMatch(<EditUserPage />, {
path: '/users/:userID/edit',
route: `/users/${userData.id}/edit`,
});

await screen.findByText('Edit User');

await user.click(screen.getByRole('button', { name: 'Edit' }));

await screen.findByText('Information has been updated by another user', {
exact: false,
});
});
});
Loading
Loading