Skip to content

Commit 2df92e6

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

File tree

15 files changed

+469
-46
lines changed

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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy