-
Notifications
You must be signed in to change notification settings - Fork 367
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: [UIE-8136] - add new users table component (part 1)
- Loading branch information
1 parent
6be3f6b
commit e1707ba
Showing
10 changed files
with
706 additions
and
35 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
This file was deleted.
Oops, something went wrong.
117 changes: 117 additions & 0 deletions
117
packages/manager/src/features/IAM/Users/UsersTable/UserRow.test.tsx
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,117 @@ | ||
import React from 'react'; | ||
|
||
import { profileFactory } from 'src/factories'; | ||
import { accountUserFactory } from 'src/factories/accountUsers'; | ||
import { HttpResponse, http, server } from 'src/mocks/testServer'; | ||
import { | ||
mockMatchMedia, | ||
renderWithTheme, | ||
wrapWithTableBody, | ||
} from 'src/utilities/testHelpers'; | ||
|
||
import { UserRow } from './UserRow'; | ||
|
||
// Because the table row hides certain columns on small viewport sizes, | ||
// we must use this. | ||
beforeAll(() => mockMatchMedia()); | ||
|
||
describe('UserRow', () => { | ||
it('renders a username and email', () => { | ||
const user = accountUserFactory.build(); | ||
|
||
const { getByText } = renderWithTheme( | ||
wrapWithTableBody(<UserRow onDelete={vi.fn()} user={user} />) | ||
); | ||
|
||
expect(getByText(user.username)).toBeVisible(); | ||
expect(getByText(user.email)).toBeVisible(); | ||
}); | ||
it('renders only a username, email, and account access status for a Proxy user', async () => { | ||
const mockLogin = { | ||
login_datetime: '2022-02-09T16:19:26', | ||
}; | ||
const proxyUser = accountUserFactory.build({ | ||
email: 'proxy@proxy.com', | ||
last_login: mockLogin, | ||
restricted: true, | ||
user_type: 'proxy', | ||
username: 'proxyUsername', | ||
}); | ||
|
||
server.use( | ||
// Mock the active profile for the child account. | ||
http.get('*/profile', () => { | ||
return HttpResponse.json(profileFactory.build({ user_type: 'child' })); | ||
}) | ||
); | ||
|
||
const { findByText, queryByText } = renderWithTheme( | ||
wrapWithTableBody(<UserRow onDelete={vi.fn()} user={proxyUser} />) | ||
); | ||
|
||
// Renders Username, Email, and Account Access fields for a proxy user. | ||
expect(await findByText('proxyUsername')).toBeInTheDocument(); | ||
expect(await findByText('proxy@proxy.com')).toBeInTheDocument(); | ||
|
||
// Does not render the Last Login for a proxy user. | ||
expect(queryByText('2022-02-09T16:19:26')).not.toBeInTheDocument(); | ||
}); | ||
|
||
it('renders "Never" if last_login is null', () => { | ||
const user = accountUserFactory.build({ last_login: null }); | ||
|
||
const { getByText } = renderWithTheme( | ||
wrapWithTableBody(<UserRow onDelete={vi.fn()} user={user} />) | ||
); | ||
|
||
expect(getByText('Never')).toBeVisible(); | ||
}); | ||
it('renders a timestamp of the last_login if it was successful', async () => { | ||
// Because we are unit testing a timestamp, set our timezone to UTC | ||
server.use( | ||
http.get('*/profile', () => { | ||
return HttpResponse.json(profileFactory.build({ timezone: 'utc' })); | ||
}) | ||
); | ||
|
||
const user = accountUserFactory.build({ | ||
last_login: { | ||
login_datetime: '2023-10-17T21:17:40', | ||
status: 'successful', | ||
}, | ||
}); | ||
|
||
const { findByText } = renderWithTheme( | ||
wrapWithTableBody(<UserRow onDelete={vi.fn()} user={user} />) | ||
); | ||
|
||
const date = await findByText('2023-10-17 21:17'); | ||
|
||
expect(date).toBeVisible(); | ||
}); | ||
it('renders a timestamp and "Failed" of the last_login if it was failed', async () => { | ||
// Because we are unit testing a timestamp, set our timezone to UTC | ||
server.use( | ||
http.get('*/profile', () => { | ||
return HttpResponse.json(profileFactory.build({ timezone: 'utc' })); | ||
}) | ||
); | ||
|
||
const user = accountUserFactory.build({ | ||
last_login: { | ||
login_datetime: '2023-10-17T21:17:40', | ||
status: 'failed', | ||
}, | ||
}); | ||
|
||
const { findByText, getByText } = renderWithTheme( | ||
wrapWithTableBody(<UserRow onDelete={vi.fn()} user={user} />) | ||
); | ||
|
||
const date = await findByText('2023-10-17 21:17'); | ||
const status = getByText('Failed'); | ||
|
||
expect(date).toBeVisible(); | ||
expect(status).toBeVisible(); | ||
}); | ||
}); |
99 changes: 99 additions & 0 deletions
99
packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx
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,99 @@ | ||
import { useTheme } from '@mui/material/styles'; | ||
import React from 'react'; | ||
|
||
import { Avatar } from 'src/components/Avatar/Avatar'; | ||
import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; | ||
import { Hidden } from 'src/components/Hidden'; | ||
import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; | ||
import { TableCell } from 'src/components/TableCell'; | ||
import { TableRow } from 'src/components/TableRow'; | ||
import { useProfile } from 'src/queries/profile/profile'; | ||
import { capitalize } from 'src/utilities/capitalize'; | ||
|
||
import { UsersActionMenu } from './UsersActionMenu'; | ||
|
||
import type { User } from '@linode/api-v4'; | ||
import { Link } from 'react-router-dom'; | ||
import { Box, Chip, Stack, Typography } from '@linode/ui'; | ||
|
||
interface Props { | ||
onDelete: (username: string) => void; | ||
user: User; | ||
} | ||
|
||
export const UserRow = ({ onDelete, user }: Props) => { | ||
const theme = useTheme(); | ||
|
||
const { data: profile } = useProfile(); | ||
|
||
const isProxyUser = Boolean(user.user_type === 'proxy'); | ||
|
||
return ( | ||
<TableRow data-qa-table-row={user.username} key={user.username}> | ||
<TableCell> | ||
<Stack alignItems="center" direction="row" spacing={1.5}> | ||
<Avatar | ||
color={ | ||
user.username !== profile?.username | ||
? theme.palette.primary.dark | ||
: undefined | ||
} | ||
username={user.username} | ||
/> | ||
<Typography> | ||
<Link to={`/iam/users/${user.username}/details`}> | ||
{user.username} | ||
</Link> | ||
</Typography> | ||
<Box display="flex" flexGrow={1} /> | ||
{user.tfa_enabled && <Chip color="success" label="2FA" />} | ||
</Stack> | ||
</TableCell> | ||
<Hidden smDown> | ||
<TableCell>{user.email}</TableCell> | ||
</Hidden> | ||
{!isProxyUser && ( | ||
<Hidden lgDown> | ||
<TableCell> | ||
<LastLogin last_login={user.last_login} /> | ||
</TableCell> | ||
</Hidden> | ||
)} | ||
<TableCell actionCell> | ||
<UsersActionMenu | ||
isProxyUser={isProxyUser} | ||
onDelete={onDelete} | ||
username={user.username} | ||
/> | ||
</TableCell> | ||
</TableRow> | ||
); | ||
}; | ||
|
||
/** | ||
* Display information about a Users last login | ||
* | ||
* - The component renders "Never" if last_login is `null` | ||
* - The component renders a date if last_login is a success | ||
* - The component renders a date and a status if last_login is a failure | ||
*/ | ||
const LastLogin = (props: Pick<User, 'last_login'>) => { | ||
const { last_login } = props; | ||
|
||
if (last_login === null) { | ||
return <Typography>Never</Typography>; | ||
} | ||
|
||
if (last_login.status === 'successful') { | ||
return <DateTimeDisplay value={last_login.login_datetime} />; | ||
} | ||
|
||
return ( | ||
<Stack alignItems="center" direction="row" spacing={1}> | ||
<DateTimeDisplay value={last_login.login_datetime} /> | ||
<Typography>—</Typography> | ||
<StatusIcon status="error" /> | ||
<Typography>{capitalize(last_login.status)}</Typography> | ||
</Stack> | ||
); | ||
}; |
92 changes: 92 additions & 0 deletions
92
packages/manager/src/features/IAM/Users/UsersTable/Users.tsx
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,92 @@ | ||
import React from 'react'; | ||
|
||
import { DocumentTitleSegment } from 'src/components/DocumentTitle'; | ||
import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; | ||
import { Table } from 'src/components/Table'; | ||
import { TableBody } from 'src/components/TableBody'; | ||
import { useOrder } from 'src/hooks/useOrder'; | ||
import { usePagination } from 'src/hooks/usePagination'; | ||
import { useAccountUsers } from 'src/queries/account/users'; | ||
import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; | ||
|
||
import type { Filter } from '@linode/api-v4'; | ||
import { Box, Button, Paper } from '@linode/ui'; | ||
import { UsersLandingTableHead } from './UsersLandingTableHead'; | ||
import { UsersLandingTableBody } from './UsersLandingTableBody'; | ||
|
||
export const UsersLanding = () => { | ||
const pagination = usePagination(1, 'account-users'); | ||
const order = useOrder(); | ||
|
||
const usersFilter: Filter = { | ||
['+order']: order.order, | ||
['+order_by']: order.orderBy, | ||
}; | ||
|
||
// Since this query is disabled for restricted users, use isLoading. | ||
const { data: users, error, isLoading } = useAccountUsers({ | ||
filters: usersFilter, | ||
params: { | ||
page: pagination.page, | ||
page_size: pagination.pageSize, | ||
}, | ||
}); | ||
|
||
const numCols = 4; | ||
|
||
const handleDelete = (username: string) => { | ||
// mock | ||
}; | ||
|
||
const handleSearch = async (value: string) => { | ||
// mock | ||
}; | ||
|
||
return ( | ||
<React.Fragment> | ||
<DocumentTitleSegment segment="Users & Grants" /> | ||
<Paper sx={(theme) => ({ marginTop: theme.spacing(2) })}> | ||
<Box | ||
sx={(theme) => ({ | ||
alignItems: 'center', | ||
display: 'flex', | ||
justifyContent: 'space-between', | ||
marginBottom: theme.spacing(2), | ||
})} | ||
> | ||
<DebouncedSearchTextField | ||
clearable | ||
debounceTime={250} | ||
hideLabel | ||
label="Filter" | ||
onSearch={handleSearch} | ||
placeholder="Filter" | ||
value="" | ||
sx={{ width: 320 }} | ||
/> | ||
<Button buttonType="primary">Add a User</Button> | ||
</Box> | ||
<Table aria-label="List of Users"> | ||
<UsersLandingTableHead order={order} /> | ||
<TableBody> | ||
<UsersLandingTableBody | ||
error={error} | ||
isLoading={isLoading} | ||
numCols={numCols} | ||
onDelete={handleDelete} | ||
users={users?.data} | ||
/> | ||
</TableBody> | ||
</Table> | ||
<PaginationFooter | ||
count={users?.results || 0} | ||
eventCategory="users landing" | ||
handlePageChange={pagination.handlePageChange} | ||
handleSizeChange={pagination.handlePageSizeChange} | ||
page={pagination.page} | ||
pageSize={pagination.pageSize} | ||
/> | ||
</Paper> | ||
</React.Fragment> | ||
); | ||
}; |
Oops, something went wrong.