Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit5bbc864

Browse files
BrunoQuaresmakylecarbs
authored andcommitted
feat: Add update user roles action (#1361)
1 parent66d59f1 commit5bbc864

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
exportconstupdateUserPassword=async(password:string,userId:TypesGen.User["id"]):Promise<undefined>=>
160160
axios.put(`/api/v2/users/${userId}/password`,{ password})
161+
162+
exportconstgetSiteRoles=async():Promise<Array<TypesGen.Role>>=>{
163+
constresponse=awaitaxios.get<Array<TypesGen.Role>>(`/api/v2/users/roles`)
164+
returnresponse.data
165+
}
166+
167+
exportconstupdateUserRoles=async(
168+
roles:TypesGen.Role["name"][],
169+
userId:TypesGen.User["id"],
170+
):Promise<TypesGen.User>=>{
171+
constresponse=awaitaxios.put<TypesGen.User>(`/api/v2/users/${userId}/roles`,{ roles})
172+
returnresponse.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+
importReactfrom"react"
3+
import{MockAdminRole,MockMemberRole,MockSiteRoles}from"../../testHelpers"
4+
import{RoleSelect,RoleSelectProps}from"./RoleSelect"
5+
6+
exportdefault{
7+
title:"components/RoleSelect",
8+
component:RoleSelect,
9+
}asComponentMeta<typeofRoleSelect>
10+
11+
constTemplate:Story<RoleSelectProps>=(args)=><RoleSelect{...args}/>
12+
13+
exportconstClose=Template.bind({})
14+
Close.args={
15+
roles:MockSiteRoles,
16+
selectedRoles:[MockAdminRole,MockMemberRole],
17+
}
18+
19+
exportconstOpen=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+
importCheckboxfrom"@material-ui/core/Checkbox"
2+
importMenuItemfrom"@material-ui/core/MenuItem"
3+
importSelectfrom"@material-ui/core/Select"
4+
import{makeStyles,Theme}from"@material-ui/core/styles"
5+
importReactfrom"react"
6+
import{Role}from"../../api/typesGenerated"
7+
8+
exportconstLanguage={
9+
label:"Roles",
10+
}
11+
exportinterfaceRoleSelectProps{
12+
roles:Role[]
13+
selectedRoles:Role[]
14+
onChange:(roles:Role["name"][])=>void
15+
loading?:boolean
16+
open?:boolean
17+
}
18+
19+
exportconstRoleSelect:React.FC<RoleSelectProps>=({ roles, selectedRoles, loading, onChange, open})=>{
20+
conststyles=useStyles()
21+
constvalue=selectedRoles.map((r)=>r.name)
22+
constrenderValue=()=>selectedRoles.map((r)=>r.display_name).join(", ")
23+
constsortedRoles=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(valueasstring[])
37+
}}
38+
>
39+
{sortedRoles.map((r)=>{
40+
constisChecked=selectedRoles.some((selectedRole)=>selectedRole.name===r.name)
41+
42+
return(
43+
<MenuItemkey={r.name}value={r.name}disabled={loading}>
44+
<Checkboxcolor="primary"checked={isChecked}/>{r.display_name}
45+
</MenuItem>
46+
)
47+
})}
48+
</Select>
49+
)
50+
}
51+
52+
constuseStyles=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-
exportconstTableHeaders:React.FC<TableHeadersProps>=({columns, hasMenu})=>{
11+
exportconstTableHeaderRow:React.FC=({children})=>{
1212
conststyles=useStyles()
13+
return<TableRowclassName={styles.root}>{children}</TableRow>
14+
}
15+
16+
exportconstTableHeaders:React.FC<TableHeadersProps>=({ columns, hasMenu})=>{
1317
return(
14-
<TableRowclassName={styles.root}>
18+
<TableHeaderRow>
1519
{columns.map((c,idx)=>(
1620
<TableCellkey={idx}size="small">
1721
{c}
1822
</TableCell>
1923
))}
2024
{/* 1% is a trick to make the table cell width fit the content */}
2125
{hasMenu&&<TableCellwidth="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
importReactfrom"react"
3-
import{MockUser,MockUser2}from"../../testHelpers"
3+
import{MockSiteRoles,MockUser,MockUser2}from"../../testHelpers"
44
import{UsersTable,UsersTableProps}from"./UsersTable"
55

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

1819
exportconstEmpty=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+
importBoxfrom"@material-ui/core/Box"
2+
importTablefrom"@material-ui/core/Table"
3+
importTableBodyfrom"@material-ui/core/TableBody"
4+
importTableCellfrom"@material-ui/core/TableCell"
5+
importTableHeadfrom"@material-ui/core/TableHead"
6+
importTableRowfrom"@material-ui/core/TableRow"
17
importReactfrom"react"
28
import{UserResponse}from"../../api/types"
9+
import*asTypesGenfrom"../../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
exportconstLanguage={
@@ -12,48 +21,79 @@ export const Language = {
1221
usernameLabel:"User",
1322
suspendMenuItem:"Suspend",
1423
resetPasswordMenuItem:"Reset password",
24+
rolesLabel:"Roles",
1525
}
1626

17-
constemptyState=<EmptyStatemessage={Language.emptyMessage}/>
18-
19-
constcolumns:Column<UserResponse>[]=[
20-
{
21-
key:"username",
22-
name:Language.usernameLabel,
23-
renderer:(field,data)=>{
24-
return<UserCellAvatar={{username:data.username}}primaryText={data.username}caption={data.email}/>
25-
},
26-
},
27-
]
28-
2927
exportinterfaceUsersTableProps{
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-
exportconstUsersTable:React.FC<UsersTableProps>=({ users, onSuspendUser, onResetUserPassword})=>{
36+
exportconstUsersTable: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+
<TableTitletitle={Language.usersTitle}/>
48+
<TableHeaderRow>
49+
<TableCellsize="small">{Language.usernameLabel}</TableCell>
50+
<TableCellsize="small">{Language.rolesLabel}</TableCell>
51+
{/* 1% is a trick to make the table cell width fit the content */}
52+
<TableCellsize="small"width="1%"/>
53+
</TableHeaderRow>
54+
</TableHead>
55+
<TableBody>
56+
{users.map((u)=>(
57+
<TableRowkey={u.id}>
58+
<TableCell>
59+
<UserCellAvatar={{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+
<TableCellcolSpan={999}>
90+
<Boxp={4}>
91+
<EmptyStatemessage={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
importReactfrom"react"
33
import*asAPIfrom"../../api"
4+
import{Role}from"../../api/typesGenerated"
45
import{GlobalSnackbar}from"../../components/GlobalSnackbar/GlobalSnackbar"
56
import{LanguageasResetPasswordDialogLanguage}from"../../components/ResetPasswordDialog/ResetPasswordDialog"
7+
import{LanguageasRoleSelectLanguage}from"../../components/RoleSelect/RoleSelect"
68
import{LanguageasUsersTableLanguage}from"../../components/UsersTable/UsersTable"
7-
import{MockUser,MockUser2,render}from"../../testHelpers"
9+
import{MockAuditorRole,MockUser,MockUser2,render}from"../../testHelpers"
810
import{LanguageasusersXServiceLanguage}from"../../xServices/users/usersXService"
911
import{LanguageasUsersPageLanguage,UsersPage}from"./UsersPage"
1012

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

67+
constupdateUserRole=async(setupActionSpies:()=>void,role:Role)=>{
68+
// Get the first user in the table
69+
constusers=awaitscreen.findAllByText(/.*@coder.com/)
70+
constfirstUserRow=users[0].closest("tr")
71+
if(!firstUserRow){
72+
thrownewError("Error on get the first user row")
73+
}
74+
75+
// Click on the "roles" menu to display the role options
76+
constrolesLabel=within(firstUserRow).getByLabelText(RoleSelectLanguage.label)
77+
constrolesMenuTrigger=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+
constlistBox=screen.getByRole("listbox")
87+
constauditorOption=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}=awaitupdateUserRole(()=>{
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+
awaitwaitFor(()=>expect(rolesMenuTrigger).toHaveTextContent("Admin, Member, Auditor"))
217+
218+
// Check if the API was called correctly
219+
constcurrentRoles=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+
awaitupdateUserRole(()=>{
235+
jest.spyOn(API,"updateUserRoles").mockRejectedValueOnce({})
236+
},MockAuditorRole)
237+
238+
// Check if the error message is displayed
239+
awaitscreen.findByText(usersXServiceLanguage.updateUserRolesError)
240+
241+
// Check if the API was called correctly
242+
constcurrentRoles=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)

[8]ページ先頭

©2009-2025 Movatter.jp