Skip to content

Commit

Permalink
feat: [UIE-8136] - add new users table component (part 1)
Browse files Browse the repository at this point in the history
  • Loading branch information
aaleksee-akamai committed Dec 5, 2024
1 parent 6be3f6b commit e1707ba
Show file tree
Hide file tree
Showing 10 changed files with 706 additions and 35 deletions.
2 changes: 1 addition & 1 deletion packages/manager/src/features/IAM/IAMLanding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { RouteComponentProps } from 'react-router-dom';
type Props = RouteComponentProps<{}>;

const Users = React.lazy(() =>
import('./Users/Users').then((module) => ({
import('./Users/UsersTable/Users').then((module) => ({
default: module.UsersLanding,
}))
);
Expand Down
34 changes: 0 additions & 34 deletions packages/manager/src/features/IAM/Users/Users.tsx

This file was deleted.

117 changes: 117 additions & 0 deletions packages/manager/src/features/IAM/Users/UsersTable/UserRow.test.tsx
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 packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx
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>&#8212;</Typography>
<StatusIcon status="error" />
<Typography>{capitalize(last_login.status)}</Typography>
</Stack>
);
};
92 changes: 92 additions & 0 deletions packages/manager/src/features/IAM/Users/UsersTable/Users.tsx
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>
);
};
Loading

0 comments on commit e1707ba

Please sign in to comment.