-
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 e11b9ef
Showing
12 changed files
with
720 additions
and
36 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 @@ | ||
--- | ||
"@linode/manager": Upcoming Features | ||
--- | ||
|
||
New users table component for iam ([#11367](https://github.com/linode/manager/pull/11367)) |
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
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 { Box, Chip, Stack, Typography } from '@linode/ui'; | ||
import { useTheme } from '@mui/material/styles'; | ||
import React from 'react'; | ||
import { Link } from 'react-router-dom'; | ||
|
||
import { Avatar } from 'src/components/Avatar/Avatar'; | ||
import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; | ||
import { MaskableText } from 'src/components/MaskableText/MaskableText'; | ||
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'; | ||
|
||
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> | ||
<MaskableText isToggleable text={user.username}> | ||
<Link to={`/iam/users/${user.username}/details`}> | ||
{user.username} | ||
</Link> | ||
</MaskableText> | ||
</Typography> | ||
<Box display="flex" flexGrow={1} /> | ||
{user.tfa_enabled && <Chip color="success" label="2FA" />} | ||
</Stack> | ||
</TableCell> | ||
<TableCell sx={{ display: { sm: 'table-cell', xs: 'none' } }}> | ||
<MaskableText isToggleable text={user.email} /> | ||
</TableCell> | ||
{!isProxyUser && ( | ||
<TableCell sx={{ display: { lg: 'table-cell', xs: 'none' } }}> | ||
<LastLogin last_login={user.last_login} /> | ||
</TableCell> | ||
)} | ||
<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> | ||
); | ||
}; |
101 changes: 101 additions & 0 deletions
101
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,101 @@ | ||
import { Box, Button, Paper } from '@linode/ui'; | ||
import { useMediaQuery } from '@mui/material'; | ||
import { useTheme } from '@mui/material/styles'; | ||
import React from 'react'; | ||
|
||
import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; | ||
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 { UsersLandingTableBody } from './UsersLandingTableBody'; | ||
import { UsersLandingTableHead } from './UsersLandingTableHead'; | ||
|
||
import type { Filter } from '@linode/api-v4'; | ||
|
||
export const UsersLanding = () => { | ||
const theme = useTheme(); | ||
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 isSmDown = useMediaQuery(theme.breakpoints.down('sm')); | ||
const isLgDown = useMediaQuery(theme.breakpoints.up('lg')); | ||
|
||
const numColsLg = isLgDown ? 4 : 3; | ||
|
||
const numCols = isSmDown ? 2 : numColsLg; | ||
|
||
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" | ||
sx={{ width: 320 }} | ||
value="" | ||
/> | ||
<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.