Skip to content

Commit 2df92e6

Browse files
feat: Add update user roles action (coder#1361)
1 parent c96d439 commit 2df92e6

15 files changed

+469
-46
lines changed

site/src/api/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,16 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise<TypesGen
158158

159159
export const updateUserPassword = async (password: string, userId: TypesGen.User["id"]): Promise<undefined> =>
160160
axios.put(`/api/v2/users/${userId}/password`, { password })
161+
162+
export const getSiteRoles = async (): Promise<Array<TypesGen.Role>> => {
163+
const response = await axios.get<Array<TypesGen.Role>>(`/api/v2/users/roles`)
164+
return response.data
165+
}
166+
167+
export const updateUserRoles = async (
168+
roles: TypesGen.Role["name"][],
169+
userId: TypesGen.User["id"],
170+
): Promise<TypesGen.User> => {
171+
const response = await axios.put<TypesGen.User>(`/api/v2/users/${userId}/roles`, { roles })
172+
return response.data
173+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ComponentMeta, Story } from "@storybook/react"
2+
import React from "react"
3+
import { MockAdminRole, MockMemberRole, MockSiteRoles } from "../../testHelpers"
4+
import { RoleSelect, RoleSelectProps } from "./RoleSelect"
5+
6+
export default {
7+
title: "components/RoleSelect",
8+
component: RoleSelect,
9+
} as ComponentMeta<typeof RoleSelect>
10+
11+
const Template: Story<RoleSelectProps> = (args) => <RoleSelect {...args} />
12+
13+
export const Close = Template.bind({})
14+
Close.args = {
15+
roles: MockSiteRoles,
16+
selectedRoles: [MockAdminRole, MockMemberRole],
17+
}
18+
19+
export const Open = Template.bind({})
20+
Open.args = {
21+
open: true,
22+
roles: MockSiteRoles,
23+
selectedRoles: [MockAdminRole, MockMemberRole],
24+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import Checkbox from "@material-ui/core/Checkbox"
2+
import MenuItem from "@material-ui/core/MenuItem"
3+
import Select from "@material-ui/core/Select"
4+
import { makeStyles, Theme } from "@material-ui/core/styles"
5+
import React from "react"
6+
import { Role } from "../../api/typesGenerated"
7+
8+
export const Language = {
9+
label: "Roles",
10+
}
11+
export interface RoleSelectProps {
12+
roles: Role[]
13+
selectedRoles: Role[]
14+
onChange: (roles: Role["name"][]) => void
15+
loading?: boolean
16+
open?: boolean
17+
}
18+
19+
export const RoleSelect: React.FC<RoleSelectProps> = ({ roles, selectedRoles, loading, onChange, open }) => {
20+
const styles = useStyles()
21+
const value = selectedRoles.map((r) => r.name)
22+
const renderValue = () => selectedRoles.map((r) => r.display_name).join(", ")
23+
const sortedRoles = roles.sort((a, b) => a.display_name.localeCompare(b.display_name))
24+
25+
return (
26+
<Select
27+
aria-label={Language.label}
28+
open={open}
29+
multiple
30+
value={value}
31+
renderValue={renderValue}
32+
variant="outlined"
33+
className={styles.select}
34+
onChange={(e) => {
35+
const { value } = e.target
36+
onChange(value as string[])
37+
}}
38+
>
39+
{sortedRoles.map((r) => {
40+
const isChecked = selectedRoles.some((selectedRole) => selectedRole.name === r.name)
41+
42+
return (
43+
<MenuItem key={r.name} value={r.name} disabled={loading}>
44+
<Checkbox color="primary" checked={isChecked} /> {r.display_name}
45+
</MenuItem>
46+
)
47+
})}
48+
</Select>
49+
)
50+
}
51+
52+
const useStyles = makeStyles((theme: Theme) => ({
53+
select: {
54+
margin: 0,
55+
// Set a fixed width for the select. It avoids selects having different sizes
56+
// depending on how many roles they have selected.
57+
width: theme.spacing(25),
58+
},
59+
}))

site/src/components/TableHeaders/TableHeaders.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,22 @@ export interface TableHeadersProps {
88
hasMenu?: boolean
99
}
1010

11-
export const TableHeaders: React.FC<TableHeadersProps> = ({ columns, hasMenu }) => {
11+
export const TableHeaderRow: React.FC = ({ children }) => {
1212
const styles = useStyles()
13+
return <TableRow className={styles.root}>{children}</TableRow>
14+
}
15+
16+
export const TableHeaders: React.FC<TableHeadersProps> = ({ columns, hasMenu }) => {
1317
return (
14-
<TableRow className={styles.root}>
18+
<TableHeaderRow>
1519
{columns.map((c, idx) => (
1620
<TableCell key={idx} size="small">
1721
{c}
1822
</TableCell>
1923
))}
2024
{/* 1% is a trick to make the table cell width fit the content */}
2125
{hasMenu && <TableCell width="1%" />}
22-
</TableRow>
26+
</TableHeaderRow>
2327
)
2428
}
2529

site/src/components/UsersTable/UsersTable.stories.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ComponentMeta, Story } from "@storybook/react"
22
import React from "react"
3-
import { MockUser, MockUser2 } from "../../testHelpers"
3+
import { MockSiteRoles, MockUser, MockUser2 } from "../../testHelpers"
44
import { UsersTable, UsersTableProps } from "./UsersTable"
55

66
export default {
@@ -13,9 +13,11 @@ const Template: Story<UsersTableProps> = (args) => <UsersTable {...args} />
1313
export const Example = Template.bind({})
1414
Example.args = {
1515
users: [MockUser, MockUser2],
16+
roles: MockSiteRoles,
1617
}
1718

1819
export const Empty = Template.bind({})
1920
Empty.args = {
2021
users: [],
22+
roles: MockSiteRoles,
2123
}
Lines changed: 75 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1+
import Box from "@material-ui/core/Box"
2+
import Table from "@material-ui/core/Table"
3+
import TableBody from "@material-ui/core/TableBody"
4+
import TableCell from "@material-ui/core/TableCell"
5+
import TableHead from "@material-ui/core/TableHead"
6+
import TableRow from "@material-ui/core/TableRow"
17
import React from "react"
28
import { UserResponse } from "../../api/types"
9+
import * as TypesGen from "../../api/typesGenerated"
310
import { EmptyState } from "../EmptyState/EmptyState"
4-
import { Column, Table } from "../Table/Table"
11+
import { RoleSelect } from "../RoleSelect/RoleSelect"
12+
import { TableHeaderRow } from "../TableHeaders/TableHeaders"
513
import { TableRowMenu } from "../TableRowMenu/TableRowMenu"
14+
import { TableTitle } from "../TableTitle/TableTitle"
615
import { UserCell } from "../UserCell/UserCell"
716

817
export const Language = {
@@ -12,48 +21,79 @@ export const Language = {
1221
usernameLabel: "User",
1322
suspendMenuItem: "Suspend",
1423
resetPasswordMenuItem: "Reset password",
24+
rolesLabel: "Roles",
1525
}
1626

17-
const emptyState = <EmptyState message={Language.emptyMessage} />
18-
19-
const columns: Column<UserResponse>[] = [
20-
{
21-
key: "username",
22-
name: Language.usernameLabel,
23-
renderer: (field, data) => {
24-
return <UserCell Avatar={{ username: data.username }} primaryText={data.username} caption={data.email} />
25-
},
26-
},
27-
]
28-
2927
export interface UsersTableProps {
3028
users: UserResponse[]
3129
onSuspendUser: (user: UserResponse) => void
3230
onResetUserPassword: (user: UserResponse) => void
31+
onUpdateUserRoles: (user: UserResponse, roles: TypesGen.Role["name"][]) => void
32+
roles: TypesGen.Role[]
33+
isUpdatingUserRoles?: boolean
3334
}
3435

35-
export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser, onResetUserPassword }) => {
36+
export const UsersTable: React.FC<UsersTableProps> = ({
37+
users,
38+
roles,
39+
onSuspendUser,
40+
onResetUserPassword,
41+
onUpdateUserRoles,
42+
isUpdatingUserRoles,
43+
}) => {
3644
return (
37-
<Table
38-
columns={columns}
39-
data={users}
40-
title={Language.usersTitle}
41-
emptyState={emptyState}
42-
rowMenu={(user) => (
43-
<TableRowMenu
44-
data={user}
45-
menuItems={[
46-
{
47-
label: Language.suspendMenuItem,
48-
onClick: onSuspendUser,
49-
},
50-
{
51-
label: Language.resetPasswordMenuItem,
52-
onClick: onResetUserPassword,
53-
},
54-
]}
55-
/>
56-
)}
57-
/>
45+
<Table>
46+
<TableHead>
47+
<TableTitle title={Language.usersTitle} />
48+
<TableHeaderRow>
49+
<TableCell size="small">{Language.usernameLabel}</TableCell>
50+
<TableCell size="small">{Language.rolesLabel}</TableCell>
51+
{/* 1% is a trick to make the table cell width fit the content */}
52+
<TableCell size="small" width="1%" />
53+
</TableHeaderRow>
54+
</TableHead>
55+
<TableBody>
56+
{users.map((u) => (
57+
<TableRow key={u.id}>
58+
<TableCell>
59+
<UserCell Avatar={{ username: u.username }} primaryText={u.username} caption={u.email} />{" "}
60+
</TableCell>
61+
<TableCell>
62+
<RoleSelect
63+
roles={roles}
64+
selectedRoles={u.roles}
65+
loading={isUpdatingUserRoles}
66+
onChange={(roles) => onUpdateUserRoles(u, roles)}
67+
/>
68+
</TableCell>
69+
<TableCell>
70+
<TableRowMenu
71+
data={u}
72+
menuItems={[
73+
{
74+
label: Language.suspendMenuItem,
75+
onClick: onSuspendUser,
76+
},
77+
{
78+
label: Language.resetPasswordMenuItem,
79+
onClick: onResetUserPassword,
80+
},
81+
]}
82+
/>
83+
</TableCell>
84+
</TableRow>
85+
))}
86+
87+
{users.length === 0 && (
88+
<TableRow>
89+
<TableCell colSpan={999}>
90+
<Box p={4}>
91+
<EmptyState message={Language.emptyMessage} />
92+
</Box>
93+
</TableCell>
94+
</TableRow>
95+
)}
96+
</TableBody>
97+
</Table>
5898
)
5999
}

site/src/pages/UsersPage/UsersPage.test.tsx

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { fireEvent, screen, waitFor, within } from "@testing-library/react"
22
import React from "react"
33
import * as API from "../../api"
4+
import { Role } from "../../api/typesGenerated"
45
import { GlobalSnackbar } from "../../components/GlobalSnackbar/GlobalSnackbar"
56
import { Language as ResetPasswordDialogLanguage } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
7+
import { Language as RoleSelectLanguage } from "../../components/RoleSelect/RoleSelect"
68
import { Language as UsersTableLanguage } from "../../components/UsersTable/UsersTable"
7-
import { MockUser, MockUser2, render } from "../../testHelpers"
9+
import { MockAuditorRole, MockUser, MockUser2, render } from "../../testHelpers"
810
import { Language as usersXServiceLanguage } from "../../xServices/users/usersXService"
911
import { Language as UsersPageLanguage, UsersPage } from "./UsersPage"
1012

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

67+
const updateUserRole = async (setupActionSpies: () => void, role: Role) => {
68+
// Get the first user in the table
69+
const users = await screen.findAllByText(/.*@coder.com/)
70+
const firstUserRow = users[0].closest("tr")
71+
if (!firstUserRow) {
72+
throw new Error("Error on get the first user row")
73+
}
74+
75+
// Click on the "roles" menu to display the role options
76+
const rolesLabel = within(firstUserRow).getByLabelText(RoleSelectLanguage.label)
77+
const rolesMenuTrigger = within(rolesLabel).getByRole("button")
78+
// For MUI v4, the Select was changed to open on mouseDown instead of click
79+
// https://github.com/mui-org/material-ui/pull/17978
80+
fireEvent.mouseDown(rolesMenuTrigger)
81+
82+
// Setup spies to check the actions after
83+
setupActionSpies()
84+
85+
// Click on the role option
86+
const listBox = screen.getByRole("listbox")
87+
const auditorOption = within(listBox).getByRole("option", { name: role.display_name })
88+
fireEvent.click(auditorOption)
89+
90+
return {
91+
rolesMenuTrigger,
92+
}
93+
}
94+
6595
describe("Users Page", () => {
6696
it("shows users", async () => {
6797
render(<UsersPage />)
@@ -164,4 +194,55 @@ describe("Users Page", () => {
164194
})
165195
})
166196
})
197+
198+
describe("Update user role", () => {
199+
describe("when it is success", () => {
200+
it("updates the roles", async () => {
201+
render(
202+
<>
203+
<UsersPage />
204+
<GlobalSnackbar />
205+
</>,
206+
)
207+
208+
const { rolesMenuTrigger } = await updateUserRole(() => {
209+
jest.spyOn(API, "updateUserRoles").mockResolvedValueOnce({
210+
...MockUser,
211+
roles: [...MockUser.roles, MockAuditorRole],
212+
})
213+
}, MockAuditorRole)
214+
215+
// Check if the select text was updated with the Auditor role
216+
await waitFor(() => expect(rolesMenuTrigger).toHaveTextContent("Admin, Member, Auditor"))
217+
218+
// Check if the API was called correctly
219+
const currentRoles = MockUser.roles.map((r) => r.name)
220+
expect(API.updateUserRoles).toBeCalledTimes(1)
221+
expect(API.updateUserRoles).toBeCalledWith([...currentRoles, MockAuditorRole.name], MockUser.id)
222+
})
223+
})
224+
225+
describe("when it fails", () => {
226+
it("shows an error message", async () => {
227+
render(
228+
<>
229+
<UsersPage />
230+
<GlobalSnackbar />
231+
</>,
232+
)
233+
234+
await updateUserRole(() => {
235+
jest.spyOn(API, "updateUserRoles").mockRejectedValueOnce({})
236+
}, MockAuditorRole)
237+
238+
// Check if the error message is displayed
239+
await screen.findByText(usersXServiceLanguage.updateUserRolesError)
240+
241+
// Check if the API was called correctly
242+
const currentRoles = MockUser.roles.map((r) => r.name)
243+
expect(API.updateUserRoles).toBeCalledTimes(1)
244+
expect(API.updateUserRoles).toBeCalledWith([...currentRoles, MockAuditorRole.name], MockUser.id)
245+
})
246+
})
247+
})
167248
})

0 commit comments

Comments
 (0)