-
Notifications
You must be signed in to change notification settings - Fork 15
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add New User Management View (#2544)
* 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
Showing
10 changed files
with
547 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
Oops, something went wrong.