Skip to content

Commit

Permalink
feat: [UIE-8136] - add new users table component (part 2)
Browse files Browse the repository at this point in the history
  • Loading branch information
aaleksee-akamai committed Jan 14, 2025
1 parent 73f9186 commit 554bb27
Show file tree
Hide file tree
Showing 10 changed files with 574 additions and 21 deletions.
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-11402-added-1736419050008.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Added
---

useCreateUserMutation for adding new users ([#11402](https://github.com/linode/manager/pull/11402))
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

proxy users table, removing users, adding users ([#11402](https://github.com/linode/manager/pull/11402))
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Notice, Typography } from '@linode/ui';
import { useSnackbar } from 'notistack';
import * as React from 'react';

import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel';
import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog';
import { useAccountUserDeleteMutation } from 'src/queries/account/users';

interface Props {
onClose: () => void;
onSuccess?: () => void;
open: boolean;
username: string;
}

export const UserDeleteConfirmation = (props: Props) => {
const { onClose: _onClose, onSuccess, open, username } = props;

const { enqueueSnackbar } = useSnackbar();

const {
error,
isPending,
mutateAsync: deleteUser,
reset,
} = useAccountUserDeleteMutation(username);

const onClose = () => {
reset(); // resets the error state of the useMutation
_onClose();
};

const onDelete = async () => {
await deleteUser();
enqueueSnackbar(`User ${username} has been deleted successfully.`, {
variant: 'success',
});
if (onSuccess) {
onSuccess();
}
onClose();
};

return (
<ConfirmationDialog
actions={
<ActionsPanel
primaryButtonProps={{
label: 'Delete User',
loading: isPending,
onClick: onDelete,
}}
secondaryButtonProps={{
label: 'Cancel',
onClick: onClose,
}}
style={{ padding: 0 }}
/>
}
error={error?.[0].reason}
onClose={onClose}
open={open}
title={`Delete user ${username}?`}
>
<Notice variant="warning">
<Typography sx={{ fontSize: '0.875rem' }}>
<strong>Warning:</strong> Deleting this User is permanent and can’t be
undone.
</Typography>
</Notice>
</ConfirmationDialog>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { fireEvent } from '@testing-library/react';
import React from 'react';

import { HttpResponse, http, server } from 'src/mocks/testServer';
import { renderWithTheme } from 'src/utilities/testHelpers';

import { CreateUserDrawer } from './CreateUserDrawer';

const props = {
onClose: vi.fn(),
open: true,
};

const testEmail = 'testuser@example.com';

describe('CreateUserDrawer', () => {
it('should render the drawer when open is true', () => {
const { getByRole } = renderWithTheme(<CreateUserDrawer {...props} />);

const dialog = getByRole('dialog');
expect(dialog).toBeInTheDocument();
});

it('should allow the user to fill out the form', () => {
const { getByLabelText, getByRole } = renderWithTheme(
<CreateUserDrawer {...props} />
);

const dialog = getByRole('dialog');
expect(dialog).toBeInTheDocument();

fireEvent.change(getByLabelText(/username/i), {
target: { value: 'testuser' },
});
fireEvent.change(getByLabelText(/email/i), {
target: { value: testEmail },
});

expect(getByLabelText(/username/i)).toHaveValue('testuser');
expect(getByLabelText(/email/i)).toHaveValue(testEmail);
});

it('should display an error message when submission fails', async () => {
server.use(
http.post('*/account/users', () => {
return HttpResponse.json(
{ error: [{ reason: 'An unexpected error occurred.' }] },
{ status: 500 }
);
})
);

const {
findByText,
getByLabelText,
getByRole,
getByTestId,
} = renderWithTheme(<CreateUserDrawer {...props} />);

const dialog = getByRole('dialog');
expect(dialog).toBeInTheDocument();

fireEvent.change(getByLabelText(/username/i), {
target: { value: 'testuser' },
});
fireEvent.change(getByLabelText(/email/i), {
target: { value: testEmail },
});
fireEvent.click(getByTestId('submit'));

const errorMessage = await findByText('An unexpected error occurred.');
expect(errorMessage).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { Box, FormControlLabel, Notice, TextField, Toggle } from '@linode/ui';
import * as React from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useHistory } from 'react-router-dom';

import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel';
import { Drawer } from 'src/components/Drawer';
import { useCreateUserMutation } from 'src/queries/account/users';

import type { User } from '@linode/api-v4/lib/account';

interface Props {
onClose: () => void;
open: boolean;
}

export const CreateUserDrawer = (props: Props) => {
const { onClose, open } = props;
const history = useHistory();
const { mutateAsync: createUserMutation } = useCreateUserMutation();

const {
control,
formState: { errors, isSubmitting },
handleSubmit,
reset,
setError,
} = useForm({
defaultValues: {
email: '',
restricted: false,
username: '',
},
});

const onSubmit = async (data: {
email: string;
restricted: boolean;
username: string;
}) => {
try {
const user: User = await createUserMutation(data);
handleClose();

if (user.restricted) {
history.push(`/account/users/${data.username}/permissions`, {
newUsername: user.username,
});
}
} catch (errors) {
for (const error of errors) {
setError(error?.field ?? 'root', { message: error.reason });
}
}
};

const handleClose = () => {
reset();
onClose();
};

return (
<Drawer onClose={handleClose} open={open} title="Add a User">
{errors.root?.message && (
<Notice text={errors.root?.message} variant="error" />
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
render={({ field, fieldState }) => (
<TextField
data-qa-create-username
errorText={fieldState.error?.message}
label="Username"
onBlur={field.onBlur}
onChange={field.onChange}
required
trimmed
value={field.value}
/>
)}
control={control}
name="username"
rules={{ required: 'Username is required' }}
/>

<Controller
render={({ field, fieldState }) => (
<TextField
data-qa-create-email
errorText={fieldState.error?.message}
label="Email"
onChange={field.onChange}
required
trimmed
type="email"
value={field.value}
/>
)}
control={control}
name="email"
rules={{ required: 'Email is required' }}
/>

<Controller
render={({ field }) => (
<FormControlLabel
control={
<Toggle
onChange={(e) => {
field.onChange(!e.target.checked);
}}
checked={!field.value}
data-qa-create-restricted
/>
}
label={`This user will have ${
field.value ? 'limited' : 'full'
} access to account features.
This can be changed later.`}
sx={{ marginTop: 1 }}
/>
)}
control={control}
name="restricted"
/>

<Box sx={{ marginTop: 1 }}>
<Notice
text="The user will be sent an email to set their password"
variant="warning"
/>
</Box>
<ActionsPanel
primaryButtonProps={{
'data-testid': 'submit',
label: 'Add User',
loading: isSubmitting,
type: 'submit',
}}
secondaryButtonProps={{
'data-testid': 'cancel',
label: 'Cancel',
onClick: handleClose,
}}
/>
</form>
</Drawer>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Typography } from '@linode/ui';
import React from 'react';

import { Table } from 'src/components/Table';
import { TableBody } from 'src/components/TableBody';
import { PARENT_USER } from 'src/features/Account/constants';
import { useAccountUsers } from 'src/queries/account/users';

import { UsersLandingProxyTableHead } from './UsersLandingProxyTableHead';
import { UsersLandingTableBody } from './UsersLandingTableBody';

import type { Order } from './UsersLandingTableHead';

interface Props {
handleDelete: (username: string) => void;
isProxyUser: boolean;
isRestrictedUser: boolean | undefined;
order: Order;
}

export const ProxyUserTable = ({
handleDelete,
isProxyUser,
isRestrictedUser,
order,
}: Props) => {
const {
data: proxyUser,
error: proxyUserError,
isLoading: isLoadingProxyUser,
} = useAccountUsers({
enabled: isProxyUser && !isRestrictedUser,
filters: { user_type: 'proxy' },
});

const proxyNumCols = 3;

return (
<>
<Typography
sx={(theme) => ({
marginBottom: theme.spacing(2),
marginTop: theme.spacing(3),
textTransform: 'capitalize',
[theme.breakpoints.down('md')]: {
marginLeft: theme.spacing(1),
},
})}
variant="h3"
>
{PARENT_USER} Settings
</Typography>

<Table aria-label="List of Parent Users">
<UsersLandingProxyTableHead order={order} />
<TableBody>
<UsersLandingTableBody
error={proxyUserError}
isLoading={isLoadingProxyUser}
numCols={proxyNumCols}
onDelete={handleDelete}
users={proxyUser?.data}
/>
</TableBody>
</Table>
</>
);
};
Loading

0 comments on commit 554bb27

Please sign in to comment.