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

Add New User Management View #2544

Merged
merged 19 commits into from
Apr 30, 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
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
arbulu89 marked this conversation as resolved.
Show resolved Hide resolved
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">
arbulu89 marked this conversation as resolved.
Show resolved Hide resolved
<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>
arbulu89 marked this conversation as resolved.
Show resolved Hide resolved
</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 = {
arbulu89 marked this conversation as resolved.
Show resolved Hide resolved
args: { users: [adminUser.build()] },
render: withContainerWrapper,
};
export const Loading = {
args: { loading: true },
render: withContainerWrapper,
};
export const EmptyUsersTable = {
render: withContainerWrapper,
};
export const UsersOverview = {
args: {
users: [
EMaksy marked this conversation as resolved.
Show resolved Hide resolved
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
Loading