4
4
* See License-AGPL.txt in the project root for license information.
5
5
*/
6
6
7
- import { TeamMemberInfo , TeamMembershipInvite } from "@gitpod/gitpod-protocol" ;
7
+ import { TeamMemberInfo , TeamMemberRole , TeamMembershipInvite } from "@gitpod/gitpod-protocol" ;
8
8
import moment from "moment" ;
9
9
import { useContext , useEffect , useState } from "react" ;
10
- import { useLocation } from "react-router" ;
10
+ import { useHistory , useLocation } from "react-router" ;
11
11
import Header from "../components/Header" ;
12
12
import DropDown from "../components/DropDown" ;
13
13
import { ItemsList , Item , ItemField , ItemFieldContextMenu } from "../components/ItemsList" ;
14
14
import Modal from "../components/Modal" ;
15
- import { getGitpodService } from "../service/service " ;
15
+ import Tooltip from "../components/Tooltip " ;
16
16
import copy from '../images/copy.svg' ;
17
+ import { getGitpodService } from "../service/service" ;
18
+ import { UserContext } from "../user-context" ;
17
19
import { TeamsContext , getCurrentTeam } from "./teams-context" ;
18
20
19
21
20
22
export default function ( ) {
21
- const { teams } = useContext ( TeamsContext ) ;
23
+ const { user } = useContext ( UserContext ) ;
24
+ const { teams, setTeams } = useContext ( TeamsContext ) ;
25
+ const history = useHistory ( ) ;
22
26
const location = useLocation ( ) ;
23
27
const team = getCurrentTeam ( location , teams ) ;
24
28
const [ members , setMembers ] = useState < TeamMemberInfo [ ] > ( [ ] ) ;
25
29
const [ genericInvite , setGenericInvite ] = useState < TeamMembershipInvite > ( ) ;
26
30
const [ showInviteModal , setShowInviteModal ] = useState < boolean > ( false ) ;
31
+ const [ searchText , setSearchText ] = useState < string > ( '' ) ;
32
+ const [ roleFilter , setRoleFilter ] = useState < TeamMemberRole | undefined > ( ) ;
27
33
28
34
useEffect ( ( ) => {
29
35
if ( ! team ) {
@@ -32,16 +38,18 @@ export default function() {
32
38
( async ( ) => {
33
39
const [ infos , invite ] = await Promise . all ( [
34
40
getGitpodService ( ) . server . getTeamMembers ( team . id ) ,
35
- getGitpodService ( ) . server . getGenericInvite ( team . id ) ] ) ;
36
-
41
+ getGitpodService ( ) . server . getGenericInvite ( team . id ) ,
42
+ ] ) ;
37
43
setMembers ( infos ) ;
38
44
setGenericInvite ( invite ) ;
39
45
} ) ( ) ;
40
46
} , [ team ] ) ;
41
47
48
+ const ownMemberInfo = members . find ( m => m . userId === user ?. id ) ;
49
+
42
50
const getInviteURL = ( inviteId : string ) => {
43
51
const link = new URL ( window . location . href ) ;
44
- link . pathname = '/join-team ' ;
52
+ link . pathname = '/teams/ join' ;
45
53
link . search = '?inviteId=' + inviteId ;
46
54
return link . href ;
47
55
}
@@ -70,6 +78,36 @@ export default function() {
70
78
}
71
79
}
72
80
81
+ const setTeamMemberRole = async ( userId : string , role : TeamMemberRole ) => {
82
+ await getGitpodService ( ) . server . setTeamMemberRole ( team ! . id , userId , role ) ;
83
+ setMembers ( await getGitpodService ( ) . server . getTeamMembers ( team ! . id ) ) ;
84
+ }
85
+
86
+ const removeTeamMember = async ( userId : string ) => {
87
+ await getGitpodService ( ) . server . removeTeamMember ( team ! . id , userId ) ;
88
+ const newTeams = await getGitpodService ( ) . server . getTeams ( ) ;
89
+ if ( newTeams . some ( t => t . id === team ! . id ) ) {
90
+ // We're still a member of this team.
91
+ const newMembers = await getGitpodService ( ) . server . getTeamMembers ( team ! . id ) ;
92
+ setMembers ( newMembers ) ;
93
+ } else {
94
+ // We're no longer a member of this team (note: we navigate away first in order to avoid a 404).
95
+ history . push ( '/' ) ;
96
+ setTeams ( newTeams ) ;
97
+ }
98
+ }
99
+
100
+ const filteredMembers = members . filter ( m => {
101
+ if ( ! ! roleFilter && m . role !== roleFilter ) {
102
+ return false ;
103
+ }
104
+ const memberSearchText = `${ m . fullName || '' } ${ m . primaryEmail || '' } ` . toLocaleLowerCase ( ) ;
105
+ if ( ! memberSearchText . includes ( searchText . toLocaleLowerCase ( ) ) ) {
106
+ return false ;
107
+ }
108
+ return true ;
109
+ } ) ;
110
+
73
111
return < >
74
112
< Header title = "Members" subtitle = "Manage team members." />
75
113
< div className = "lg:px-28 px-10" >
@@ -78,19 +116,19 @@ export default function() {
78
116
< div className = "py-4" >
79
117
< svg xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 16 16" width = "16" height = "16" > < path fill = "#A8A29E" d = "M6 2a4 4 0 100 8 4 4 0 000-8zM0 6a6 6 0 1110.89 3.477l4.817 4.816a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 010 6z" /> </ svg >
80
118
</ div >
81
- < input type = "search" placeholder = "Search Members" onChange = { ( ) => { /* TODO */ } } />
119
+ < input type = "search" placeholder = "Search Members" onChange = { e => setSearchText ( e . target . value ) } />
82
120
</ div >
83
121
< div className = "flex-1" />
84
122
< div className = "py-3 pl-3" >
85
- < DropDown prefix = "Role: " contextMenuWidth = "w-32" activeEntry = { ' All'} entries = { [ {
123
+ < DropDown prefix = "Role: " contextMenuWidth = "w-32" activeEntry = { roleFilter === 'owner' ? 'Owner' : ( roleFilter === 'member' ? 'Member' : ' All') } entries = { [ {
86
124
title : 'All' ,
87
- onClick : ( ) => { /* TODO */ }
125
+ onClick : ( ) => setRoleFilter ( undefined )
88
126
} , {
89
127
title : 'Owner' ,
90
- onClick : ( ) => { /* TODO */ }
128
+ onClick : ( ) => setRoleFilter ( 'owner' )
91
129
} , {
92
130
title : 'Member' ,
93
- onClick : ( ) => { /* TODO */ }
131
+ onClick : ( ) => setRoleFilter ( 'member' )
94
132
} ] } />
95
133
</ div >
96
134
< button onClick = { ( ) => setShowInviteModal ( true ) } className = "ml-2" > Invite Members</ button >
@@ -100,36 +138,54 @@ export default function() {
100
138
< ItemField >
101
139
< span className = "pl-14" > Name</ span >
102
140
</ ItemField >
103
- < ItemField >
141
+ < ItemField className = "flex items-center space-x-1" >
104
142
< span > Joined</ span >
143
+ < svg xmlns = "http://www.w3.org/2000/svg" fill = "none" className = "h-4 w-4" viewBox = "0 0 16 16" > < path fill = "#A8A29E" fill-rule = "evenodd" d = "M13.366 8.234a.8.8 0 010 1.132l-4.8 4.8a.8.8 0 01-1.132 0l-4.8-4.8a.8.8 0 111.132-1.132L7.2 11.67V2.4a.8.8 0 111.6 0v9.269l3.434-3.435a.8.8 0 011.132 0z" clip-rule = "evenodd" /> </ svg >
105
144
</ ItemField >
106
145
< ItemField className = "flex items-center" >
107
146
< span className = "flex-grow" > Role</ span >
108
147
< ItemFieldContextMenu />
109
148
</ ItemField >
110
149
</ Item >
111
- { members . map ( m => < Item className = "grid grid-cols-3" >
112
- < ItemField className = "flex items-center" >
113
- < div className = "w-14" > { m . avatarUrl && < img className = "rounded-full w-8 h-8" src = { m . avatarUrl || '' } alt = { m . fullName } /> } </ div >
114
- < div >
115
- < div className = "text-base text-gray-900 dark:text-gray-50 font-medium" > { m . fullName } </ div >
116
- < p > { m . primaryEmail } </ p >
117
- </ div >
118
- </ ItemField >
119
- < ItemField >
120
- < span className = "text-gray-400" > { moment ( m . memberSince ) . fromNow ( ) } </ span >
121
- </ ItemField >
122
- < ItemField className = "flex items-center" >
123
- < span className = "text-gray-400 flex-grow capitalize" > { m . role } </ span >
124
- < ItemFieldContextMenu menuEntries = { [
125
- {
126
- title : 'Remove' ,
127
- customFontStyle : 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300' ,
128
- onClick : ( ) => { /* TODO(janx) */ }
129
- } ,
130
- ] } />
131
- </ ItemField >
132
- </ Item > ) }
150
+ { filteredMembers . length === 0
151
+ ? < p className = "pt-16 text-center" > No members found</ p >
152
+ : filteredMembers . map ( m => < Item className = "grid grid-cols-3" key = { m . userId } >
153
+ < ItemField className = "flex items-center" >
154
+ < div className = "w-14" > { m . avatarUrl && < img className = "rounded-full w-8 h-8" src = { m . avatarUrl || '' } alt = { m . fullName } /> } </ div >
155
+ < div >
156
+ < div className = "text-base text-gray-900 dark:text-gray-50 font-medium" > { m . fullName } </ div >
157
+ < p > { m . primaryEmail } </ p >
158
+ </ div >
159
+ </ ItemField >
160
+ < ItemField >
161
+ < span className = "text-gray-400" > { moment ( m . memberSince ) . fromNow ( ) } </ span >
162
+ </ ItemField >
163
+ < ItemField className = "flex items-center" >
164
+ < span className = "text-gray-400 capitalize" > { ownMemberInfo ?. role !== 'owner'
165
+ ? m . role
166
+ : < DropDown contextMenuWidth = "w-32" activeEntry = { m . role } entries = { [ {
167
+ title : 'owner' ,
168
+ onClick : ( ) => setTeamMemberRole ( m . userId , 'owner' )
169
+ } , {
170
+ title : 'member' ,
171
+ onClick : ( ) => setTeamMemberRole ( m . userId , 'member' )
172
+ } ] } /> } </ span >
173
+ < span className = "flex-grow" />
174
+ < ItemFieldContextMenu menuEntries = { m . userId === user ?. id
175
+ ? [ {
176
+ title : 'Leave Team' ,
177
+ customFontStyle : 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300' ,
178
+ onClick : ( ) => removeTeamMember ( m . userId )
179
+ } ]
180
+ : ( ownMemberInfo ?. role === 'owner'
181
+ ? [ {
182
+ title : 'Remove' ,
183
+ customFontStyle : 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300' ,
184
+ onClick : ( ) => removeTeamMember ( m . userId )
185
+ } ]
186
+ : undefined ) } />
187
+ </ ItemField >
188
+ </ Item > ) }
133
189
</ ItemsList >
134
190
</ div >
135
191
{ genericInvite && showInviteModal && < Modal visible = { true } onClose = { ( ) => setShowInviteModal ( false ) } >
@@ -139,10 +195,14 @@ export default function() {
139
195
< div className = "w-full relative" >
140
196
< input name = "inviteUrl" disabled = { true } readOnly = { true } type = "text" value = { getInviteURL ( genericInvite . id ) } className = "rounded-md w-full truncate pr-8" />
141
197
< div className = "cursor-pointer" onClick = { ( ) => copyToClipboard ( getInviteURL ( genericInvite . id ) ) } >
142
- < img src = { copy } title = "Copy Invite URL" className = "absolute top-1/3 right-3" />
198
+ < div className = "absolute top-1/3 right-3" >
199
+ < Tooltip content = { copied ? 'Copied!' : 'Copy Invite URL' } >
200
+ < img src = { copy } title = "Copy Invite URL" />
201
+ </ Tooltip >
202
+ </ div >
143
203
</ div >
144
204
</ div >
145
- < p className = "mt-1 text-gray-500 text-sm" > { copied ? 'Copied to clipboard!' : ' Use this URL to join this team as a Member.' } </ p >
205
+ < p className = "mt-1 text-gray-500 text-sm" > Use this URL to join this team as a Member.</ p >
146
206
</ div >
147
207
< div className = "flex justify-end mt-6 space-x-2" >
148
208
< button className = "secondary" onClick = { ( ) => resetInviteLink ( ) } > Reset Invite Link</ button >
0 commit comments