Skip to content

Commit

Permalink
Add New User Management View (#2544)
Browse files Browse the repository at this point in the history
* Add users view

* Add users view test

* Add users story

* Refactor and rename code

* Refactor file structure and imports

* Refactor users component

* Refactor user component and delete unused views

* Enrich user's story

* Remove empty components

* Add banner and update test

* Refactor users page and users

* Disable Create button while loading users

* Address comments

* Fix storybook

* Refactor test and test for deletion api call

* Improve factory and texts

* order and clean up code

* Split up tests and clean up

* Address final pr comments
  • Loading branch information
EMaksy authored Apr 30, 2024
1 parent 9026a13 commit 1fcf1eb
Show file tree
Hide file tree
Showing 10 changed files with 547 additions and 0 deletions.
5 changes: 5 additions & 0 deletions assets/js/lib/api/users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { networkClient } from '@lib/network';

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

export const deleteUser = (userID) => networkClient.delete(`/users/${userID}`);
22 changes: 22 additions & 0 deletions assets/js/lib/test-utils/factories/users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { faker } from '@faker-js/faker';
import { Factory } from 'fishery';
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(),
}));

export const adminUser = userFactory.params({
id: 1,
username: 'admin',
created_at: formatISO(faker.date.past()),
enabled: true,
fullname: 'Trento Admin',
email: 'admin@trento.suse.com',
});
6 changes: 6 additions & 0 deletions assets/js/pages/Layout/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
EOS_SETTINGS,
EOS_KEYBOARD_DOUBLE_ARROW_LEFT,
EOS_KEYBOARD_DOUBLE_ARROW_RIGHT,
EOS_SUPERVISED_USER_CIRCLE_OUTLINED,
} from 'eos-icons-react';

import TrentoLogo from '@static/trento-logo-stacked.svg';
Expand Down Expand Up @@ -47,6 +48,11 @@ const navigation = [
href: '/catalog',
icon: EOS_LIST,
},
{
name: 'Users',
href: '/users',
icon: EOS_SUPERVISED_USER_CIRCLE_OUTLINED,
},
{
name: 'Settings',
href: '/settings',
Expand Down
157 changes: 157 additions & 0 deletions assets/js/pages/Users/Users.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { noop } from 'lodash';
import { format, parseISO } from 'date-fns';

import Banner from '@common/Banners/Banner';
import Button from '@common/Button';
import Modal from '@common/Modal';
import PageHeader from '@common/PageHeader';
import Table from '@common/Table';
import Tooltip from '@common/Tooltip';

const defaultUsers = [];

function Users({
onDeleteUser = noop,
navigate = noop,
users = defaultUsers,
loading = false,
}) {
const [modalOpen, setModalOpen] = useState(false);
const [user, setUser] = useState(null);

const usersTableConfig = {
pagination: true,
usePadding: false,
columns: [
{
title: 'Username',
key: 'username',
render: (content, item) => (
<Link
className="text-jungle-green-500 hover:opacity-75"
to={`/users/${item.id}/edit`}
>
{content}
</Link>
),
},
{
title: 'Full Name',
key: 'fullname',
},
{
title: 'Email',
key: 'email',
},
{
title: 'Status',
key: 'enabled',
render: (content, item) => (
<span>{item.enabled ? 'Enabled' : 'Disabled'}</span>
),
},
{
title: 'Created',
key: 'created_at',
render: (content, item) => (
<span>{format(parseISO(item.created_at), 'MMMM dd, yyyy')}</span>
),
},
{
title: 'Actions',
key: 'actions',
render: (content, item) => (
<Button
className="text-red-500 text-left w-auto"
size="small"
type="transparent"
disabled={item.id === 1}
onClick={() => {
setModalOpen(true);
setUser(item);
}}
>
<Tooltip
content="Admin user cannot be deleted"
isEnabled={item.id === 1}
>
Delete
</Tooltip>
</Button>
),
},
],
};

return (
<div className="flex flex-wrap">
<div className="flex w-1/2 h-auto overflow-hidden overflow-ellipsis break-words">
<PageHeader className="font-bold">Users</PageHeader>
</div>
<div className="flex w-1/2 justify-end">
<div className="flex w-fit whitespace-nowrap">
<Button
className="inline-block mx-1"
size="small"
disabled={loading}
onClick={() => navigate('/users/new')}
>
Create User
</Button>
</div>
</div>
<Modal
open={modalOpen}
className="!w-3/4 !max-w-3xl"
onClose={() => setModalOpen(false)}
title="Delete User"
>
<div className="flex flex-col my-2">
<Banner type="warning">
<span className="text-sm">This action cannot be undone.</span>
</Banner>
<span className="my-1 text-gray-500">
Are you sure you want to delete the following user account?
</span>

<span className="my-1 mb-4 text-gray-600">{user?.username}</span>

<div className="w-1/6 h-4/5 flex">
<Button
type="danger-bold"
className=" mr-4"
onClick={() => {
onDeleteUser(user.id);
setModalOpen(false);
setUser(null);
}}
>
Delete
</Button>

<Button
type="primary-white"
className="w-1/6"
onClick={() => {
setModalOpen(false);
setUser(null);
}}
>
Cancel
</Button>
</div>
</div>
</Modal>

<Table
config={usersTableConfig}
data={loading ? defaultUsers : users}
emptyStateText={loading ? 'Loading...' : 'No data available'}
/>
</div>
);
}

export default Users;
77 changes: 77 additions & 0 deletions assets/js/pages/Users/Users.stories.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';

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

import Users from './Users';

function ContainerWrapper({ children }) {
return (
<div className="flex flex-wrap max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
{children}
</div>
);
}

const withContainerWrapper = (args) => (
<ContainerWrapper>
<Users {...args} />
</ContainerWrapper>
);

export default {
title: 'Layouts/Users',
component: Users,
decorators: [
(Story) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],

argTypes: {
onDeleteUser: {
description: 'Function to handle deleting a user',
control: { type: 'function' },
action: 'onDeleteUser',
},
navigate: {
description: 'Function to navigate pages',
control: { type: 'function' },
action: 'navigate',
},
users: {
description: 'Array of users',
control: { type: 'object' },
},
loading: {
description: 'Display loading state of the component',
control: { type: 'boolean' },
},
},
};

export const Default = {
args: { users: [adminUser.build()] },
render: withContainerWrapper,
};
export const Loading = {
args: { loading: true },
render: withContainerWrapper,
};
export const EmptyUsersTable = {
render: withContainerWrapper,
};
export const UsersOverview = {
args: {
users: [
adminUser.build(),
userFactory.build(),
userFactory.build(),
userFactory.build(),
userFactory.build(),
],
},
render: withContainerWrapper,
};
98 changes: 98 additions & 0 deletions assets/js/pages/Users/Users.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import React from 'react';
import '@testing-library/jest-dom';
import { screen, render } from '@testing-library/react';
import { adminUser, userFactory } from '@lib/test-utils/factories/users';
import { renderWithRouter } from '@lib/test-utils';
import { userEvent } from '@testing-library/user-event';
import Users from './Users';

describe('Users', () => {
it('should render a loading table with a disabled create user button', () => {
render(<Users loading />);

const headers = [
'Username',
'Full Name',
'Email',
'Status',
'Created',
'Actions',
];
headers.forEach((headerText) => {
expect(screen.getByText(headerText)).toBeInTheDocument();
});
const button = screen.getByRole('button', { name: /Create User/i });
expect(button).toBeDisabled();
expect(screen.getByText('Loading...')).toBeVisible();
});

it('should render an empty table with an enabled create user button', () => {
render(<Users loading={false} />);

const button = screen.getByRole('button', { name: /Create User/i });
expect(button).not.toBeDisabled();
expect(screen.getByText('No data available')).toBeVisible();
});

it('should render a table with users', async () => {
const creationTime = [
'2024-03-22T16:20:57.801758Z',
'2024-04-22T16:20:57.801758Z',
];
const expectedCreationTime = ['March 22, 2024', 'April 22, 2024'];
const admin = adminUser.build({
enabled: true,
created_at: creationTime[0],
});
const user = userFactory.build({
enabled: false,
created_at: creationTime[1],
});
const users = [admin, user];

renderWithRouter(<Users users={users} loading={false} />);

expect(screen.getByText(admin.username)).toBeVisible();
expect(screen.getByText(admin.fullname)).toBeVisible();
expect(screen.getByText(admin.email)).toBeVisible();
expect(screen.getAllByText('Enabled').length).toBe(1);
expect(screen.getByText(expectedCreationTime[0])).toBeVisible();

expect(screen.getByText(user.username)).toBeVisible();
expect(screen.getByText(user.fullname)).toBeVisible();
expect(screen.getByText(user.email)).toBeVisible();
expect(screen.getAllByText('Disabled').length).toBe(1);
expect(screen.getByText(expectedCreationTime[1])).toBeVisible();

const toolTipText = 'Admin user cannot be deleted';
const deleteButtons = screen.getAllByText('Delete');
expect(deleteButtons.length).toBe(2);
await userEvent.hover(deleteButtons[0]);
expect(await screen.findByText(toolTipText)).toBeVisible();
});

it('should open modal when delete button is pressed and close when cancel button is pressed', async () => {
const user = userFactory.build();
const users = [adminUser.build(), user];

renderWithRouter(<Users users={users} loading={false} />);

const modalHeader = 'Delete User';
const bannerText = 'This action cannot be undone.';
const modalWarningText =
'Are you sure you want to delete the following user account?';

const deleteButtons = screen.getAllByText('Delete');
expect(deleteButtons.length).toBe(2);
await userEvent.click(deleteButtons[1]);
expect(screen.getByText(bannerText)).toBeVisible();
expect(screen.getByText(modalWarningText)).toBeVisible();
expect(screen.getAllByText(user.username)[1]).toBeVisible();

const cancelButton = screen.getByRole('button', { name: /Cancel/i });
const modalTitel = screen.getByText(modalHeader);
expect(modalTitel).toBeInTheDocument();
await userEvent.click(cancelButton);
expect(modalTitel).not.toBeInTheDocument();
});
});
Loading

0 comments on commit 1fcf1eb

Please sign in to comment.