Skip to content

feat: Add update user roles action #1361

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
May 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions site/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,16 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise<TypesGen

export const updateUserPassword = async (password: string, userId: TypesGen.User["id"]): Promise<undefined> =>
axios.put(`/api/v2/users/${userId}/password`, { password })

export const getSiteRoles = async (): Promise<Array<TypesGen.Role>> => {
const response = await axios.get<Array<TypesGen.Role>>(`/api/v2/users/roles`)
return response.data
}

export const updateUserRoles = async (
roles: TypesGen.Role["name"][],
userId: TypesGen.User["id"],
): Promise<TypesGen.User> => {
const response = await axios.put<TypesGen.User>(`/api/v2/users/${userId}/roles`, { roles })
return response.data
}
24 changes: 24 additions & 0 deletions site/src/components/RoleSelect/RoleSelect.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BrunoQuaresma Just looking at merged PRs to learn - no action necessary here. Curious if there's a reason we're importing React given we're on version 17 instead of silencing the 'React must be in scope' warnings.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, I think it is automatically added by an eslint role, but I'm not 100% sure.

import { MockAdminRole, MockMemberRole, MockSiteRoles } from "../../testHelpers"
import { RoleSelect, RoleSelectProps } from "./RoleSelect"

export default {
title: "components/RoleSelect",
component: RoleSelect,
} as ComponentMeta<typeof RoleSelect>

const Template: Story<RoleSelectProps> = (args) => <RoleSelect {...args} />

export const Close = Template.bind({})
Close.args = {
roles: MockSiteRoles,
selectedRoles: [MockAdminRole, MockMemberRole],
}

export const Open = Template.bind({})
Open.args = {
open: true,
roles: MockSiteRoles,
selectedRoles: [MockAdminRole, MockMemberRole],
}
59 changes: 59 additions & 0 deletions site/src/components/RoleSelect/RoleSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Checkbox from "@material-ui/core/Checkbox"
import MenuItem from "@material-ui/core/MenuItem"
import Select from "@material-ui/core/Select"
import { makeStyles, Theme } from "@material-ui/core/styles"
import React from "react"
import { Role } from "../../api/typesGenerated"

export const Language = {
label: "Roles",
}
export interface RoleSelectProps {
roles: Role[]
selectedRoles: Role[]
onChange: (roles: Role["name"][]) => void
loading?: boolean
open?: boolean
}

export const RoleSelect: React.FC<RoleSelectProps> = ({ roles, selectedRoles, loading, onChange, open }) => {
const styles = useStyles()
const value = selectedRoles.map((r) => r.name)
const renderValue = () => selectedRoles.map((r) => r.display_name).join(", ")
const sortedRoles = roles.sort((a, b) => a.display_name.localeCompare(b.display_name))

return (
<Select
aria-label={Language.label}
open={open}
multiple
value={value}
renderValue={renderValue}
variant="outlined"
className={styles.select}
onChange={(e) => {
const { value } = e.target
onChange(value as string[])
}}
>
{sortedRoles.map((r) => {
const isChecked = selectedRoles.some((selectedRole) => selectedRole.name === r.name)

return (
<MenuItem key={r.name} value={r.name} disabled={loading}>
<Checkbox color="primary" checked={isChecked} /> {r.display_name}
</MenuItem>
)
})}
</Select>
)
}

const useStyles = makeStyles((theme: Theme) => ({
select: {
margin: 0,
// Set a fixed width for the select. It avoids selects having different sizes
// depending on how many roles they have selected.
width: theme.spacing(25),
},
}))
10 changes: 7 additions & 3 deletions site/src/components/TableHeaders/TableHeaders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,22 @@ export interface TableHeadersProps {
hasMenu?: boolean
}

export const TableHeaders: React.FC<TableHeadersProps> = ({ columns, hasMenu }) => {
export const TableHeaderRow: React.FC = ({ children }) => {
const styles = useStyles()
return <TableRow className={styles.root}>{children}</TableRow>
}

export const TableHeaders: React.FC<TableHeadersProps> = ({ columns, hasMenu }) => {
return (
<TableRow className={styles.root}>
<TableHeaderRow>
{columns.map((c, idx) => (
<TableCell key={idx} size="small">
{c}
</TableCell>
))}
{/* 1% is a trick to make the table cell width fit the content */}
{hasMenu && <TableCell width="1%" />}
</TableRow>
</TableHeaderRow>
)
}

Expand Down
4 changes: 3 additions & 1 deletion site/src/components/UsersTable/UsersTable.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
import { MockUser, MockUser2 } from "../../testHelpers"
import { MockSiteRoles, MockUser, MockUser2 } from "../../testHelpers"
import { UsersTable, UsersTableProps } from "./UsersTable"

export default {
Expand All @@ -13,9 +13,11 @@ const Template: Story<UsersTableProps> = (args) => <UsersTable {...args} />
export const Example = Template.bind({})
Example.args = {
users: [MockUser, MockUser2],
roles: MockSiteRoles,
}

export const Empty = Template.bind({})
Empty.args = {
users: [],
roles: MockSiteRoles,
}
110 changes: 75 additions & 35 deletions site/src/components/UsersTable/UsersTable.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import Box from "@material-ui/core/Box"
import Table from "@material-ui/core/Table"
import TableBody from "@material-ui/core/TableBody"
import TableCell from "@material-ui/core/TableCell"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import React from "react"
import { UserResponse } from "../../api/types"
import * as TypesGen from "../../api/typesGenerated"
import { EmptyState } from "../EmptyState/EmptyState"
import { Column, Table } from "../Table/Table"
import { RoleSelect } from "../RoleSelect/RoleSelect"
import { TableHeaderRow } from "../TableHeaders/TableHeaders"
import { TableRowMenu } from "../TableRowMenu/TableRowMenu"
import { TableTitle } from "../TableTitle/TableTitle"
import { UserCell } from "../UserCell/UserCell"

export const Language = {
Expand All @@ -12,48 +21,79 @@ export const Language = {
usernameLabel: "User",
suspendMenuItem: "Suspend",
resetPasswordMenuItem: "Reset password",
rolesLabel: "Roles",
}

const emptyState = <EmptyState message={Language.emptyMessage} />

const columns: Column<UserResponse>[] = [
{
key: "username",
name: Language.usernameLabel,
renderer: (field, data) => {
return <UserCell Avatar={{ username: data.username }} primaryText={data.username} caption={data.email} />
},
},
]

export interface UsersTableProps {
users: UserResponse[]
onSuspendUser: (user: UserResponse) => void
onResetUserPassword: (user: UserResponse) => void
onUpdateUserRoles: (user: UserResponse, roles: TypesGen.Role["name"][]) => void
roles: TypesGen.Role[]
isUpdatingUserRoles?: boolean
}

export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser, onResetUserPassword }) => {
export const UsersTable: React.FC<UsersTableProps> = ({
users,
roles,
onSuspendUser,
onResetUserPassword,
onUpdateUserRoles,
isUpdatingUserRoles,
}) => {
return (
<Table
columns={columns}
data={users}
title={Language.usersTitle}
emptyState={emptyState}
rowMenu={(user) => (
<TableRowMenu
data={user}
menuItems={[
{
label: Language.suspendMenuItem,
onClick: onSuspendUser,
},
{
label: Language.resetPasswordMenuItem,
onClick: onResetUserPassword,
},
]}
/>
)}
/>
<Table>
<TableHead>
<TableTitle title={Language.usersTitle} />
<TableHeaderRow>
<TableCell size="small">{Language.usernameLabel}</TableCell>
<TableCell size="small">{Language.rolesLabel}</TableCell>
{/* 1% is a trick to make the table cell width fit the content */}
<TableCell size="small" width="1%" />
</TableHeaderRow>
</TableHead>
<TableBody>
{users.map((u) => (
<TableRow key={u.id}>
<TableCell>
<UserCell Avatar={{ username: u.username }} primaryText={u.username} caption={u.email} />{" "}
</TableCell>
<TableCell>
<RoleSelect
roles={roles}
selectedRoles={u.roles}
loading={isUpdatingUserRoles}
onChange={(roles) => onUpdateUserRoles(u, roles)}
/>
</TableCell>
<TableCell>
<TableRowMenu
data={u}
menuItems={[
{
label: Language.suspendMenuItem,
onClick: onSuspendUser,
},
{
label: Language.resetPasswordMenuItem,
onClick: onResetUserPassword,
},
]}
/>
</TableCell>
</TableRow>
))}

{users.length === 0 && (
<TableRow>
<TableCell colSpan={999}>
<Box p={4}>
<EmptyState message={Language.emptyMessage} />
</Box>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)
}
83 changes: 82 additions & 1 deletion site/src/pages/UsersPage/UsersPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react"
import React from "react"
import * as API from "../../api"
import { Role } from "../../api/typesGenerated"
import { GlobalSnackbar } from "../../components/GlobalSnackbar/GlobalSnackbar"
import { Language as ResetPasswordDialogLanguage } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
import { Language as RoleSelectLanguage } from "../../components/RoleSelect/RoleSelect"
import { Language as UsersTableLanguage } from "../../components/UsersTable/UsersTable"
import { MockUser, MockUser2, render } from "../../testHelpers"
import { MockAuditorRole, MockUser, MockUser2, render } from "../../testHelpers"
import { Language as usersXServiceLanguage } from "../../xServices/users/usersXService"
import { Language as UsersPageLanguage, UsersPage } from "./UsersPage"

Expand Down Expand Up @@ -62,6 +64,34 @@ const resetUserPassword = async (setupActionSpies: () => void) => {
fireEvent.click(confirmButton)
}

const updateUserRole = async (setupActionSpies: () => void, role: Role) => {
// Get the first user in the table
const users = await screen.findAllByText(/.*@coder.com/)
const firstUserRow = users[0].closest("tr")
if (!firstUserRow) {
throw new Error("Error on get the first user row")
}

// Click on the "roles" menu to display the role options
const rolesLabel = within(firstUserRow).getByLabelText(RoleSelectLanguage.label)
const rolesMenuTrigger = within(rolesLabel).getByRole("button")
// For MUI v4, the Select was changed to open on mouseDown instead of click
// https://github.com/mui-org/material-ui/pull/17978
fireEvent.mouseDown(rolesMenuTrigger)

// Setup spies to check the actions after
setupActionSpies()

// Click on the role option
const listBox = screen.getByRole("listbox")
const auditorOption = within(listBox).getByRole("option", { name: role.display_name })
fireEvent.click(auditorOption)

return {
rolesMenuTrigger,
}
}

describe("Users Page", () => {
it("shows users", async () => {
render(<UsersPage />)
Expand Down Expand Up @@ -164,4 +194,55 @@ describe("Users Page", () => {
})
})
})

describe("Update user role", () => {
describe("when it is success", () => {
it("updates the roles", async () => {
render(
<>
<UsersPage />
<GlobalSnackbar />
</>,
)

const { rolesMenuTrigger } = await updateUserRole(() => {
jest.spyOn(API, "updateUserRoles").mockResolvedValueOnce({
...MockUser,
roles: [...MockUser.roles, MockAuditorRole],
})
}, MockAuditorRole)

// Check if the select text was updated with the Auditor role
await waitFor(() => expect(rolesMenuTrigger).toHaveTextContent("Admin, Member, Auditor"))

// Check if the API was called correctly
const currentRoles = MockUser.roles.map((r) => r.name)
expect(API.updateUserRoles).toBeCalledTimes(1)
expect(API.updateUserRoles).toBeCalledWith([...currentRoles, MockAuditorRole.name], MockUser.id)
})
})

describe("when it fails", () => {
it("shows an error message", async () => {
render(
<>
<UsersPage />
<GlobalSnackbar />
</>,
)

await updateUserRole(() => {
jest.spyOn(API, "updateUserRoles").mockRejectedValueOnce({})
}, MockAuditorRole)

// Check if the error message is displayed
await screen.findByText(usersXServiceLanguage.updateUserRolesError)

// Check if the API was called correctly
const currentRoles = MockUser.roles.map((r) => r.name)
expect(API.updateUserRoles).toBeCalledTimes(1)
expect(API.updateUserRoles).toBeCalledWith([...currentRoles, MockAuditorRole.name], MockUser.id)
})
})
})
})
Loading