Skip to content

Commit 991c7d3

Browse files
committed
[dashboard] Improve team members page
- Fix memberSince date - Replace paths /{new,join}-team with /teams/{new,join} - Implement minimal top menu layout for full-page forms (e.g. new team/project) - Implement removing members & leaving teams - Implement member search & role filter - Implement changing team member roles
1 parent 5651cfc commit 991c7d3

File tree

14 files changed

+204
-77
lines changed

14 files changed

+204
-77
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
44

55
## June 2021
66

7+
- Implement a new Teams UI in the dashboard (behind a feature flag). ([#4401)](https://github.com/gitpod-io/gitpod/pull/4401), )
78
- Breaking Change: Make ports configured in `.gitpod.yml` private by default when no value for `visibility` is given (was public). This change is for security reasons. ([#4548](https://github.com/gitpod-io/gitpod/pull/4548))
89
- Fix active workspace list in dashboard (show also older pinned workspaces) ([#4523](https://github.com/gitpod-io/gitpod/pull/4523))
910
- Adding `ItemsList` component as a more maintainable and consistent way to render a list of workspaces, git integrations, environment variables, etc. ([#4454](https://github.com/gitpod-io/gitpod/pull/4454))

components/dashboard/src/App.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,6 @@ function App() {
159159
<Route path="/integrations" exact component={Integrations} />
160160
<Route path="/notifications" exact component={Notifications} />
161161
<Route path="/plans" exact component={Plans} />
162-
<Route path="/teams" exact component={Teams} />
163162
<Route path="/variables" exact component={EnvironmentVariables} />
164163
<Route path="/preferences" exact component={Preferences} />
165164
<Route path="/install-github-app" exact component={InstallGitHubApp} />
@@ -189,8 +188,11 @@ function App() {
189188
<p className="mt-4 text-lg text-gitpod-red">{decodeURIComponent(getURLHash())}</p>
190189
</div>
191190
</Route>
192-
<Route path="/new-team" exact component={NewTeam} />
193-
<Route path="/join-team" exact component={JoinTeam} />
191+
<Route path="/teams">
192+
<Route exact path="/teams" component={Teams} />
193+
<Route exact path="/teams/new" component={NewTeam} />
194+
<Route exact path="/teams/join" component={JoinTeam} />
195+
</Route>
194196
{(teams || []).map(team => <Route path={`/${team.slug}`}>
195197
<Route exact path={`/${team.slug}`}>
196198
<Redirect to={`/${team.slug}/projects`} />

components/dashboard/src/Menu.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ export default function Menu() {
5151
const showTeamsUI = user?.rolesOrPermissions?.includes('teams-and-projects') || window.location.hostname.endsWith('gitpod-dev.com') || window.location.hostname.endsWith('gitpod-io-dev.com');
5252
const team = getCurrentTeam(location, teams);
5353

54+
// Hide most of the top menu when in a full-page form.
55+
const isMinimalUI = ['/new', '/teams/new'].includes(location.pathname);
56+
5457
const [ teamMembers, setTeamMembers ] = useState<Record<string, TeamMemberInfo[]>>({});
5558
useEffect(() => {
5659
if (!showTeamsUI || !teams) {
@@ -161,7 +164,7 @@ export default function Menu() {
161164
<span className="flex-1 font-semibold">New Team</span>
162165
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" className="w-3.5"><path fill="currentColor" fill-rule="evenodd" d="M7 0a1 1 0 011 1v5h5a1 1 0 110 2H8v5a1 1 0 11-2 0V8H1a1 1 0 010-2h5V1a1 1 0 011-1z" clip-rule="evenodd" /></svg>
163166
</div>,
164-
onClick: () => history.push("/new-team"),
167+
onClick: () => history.push("/teams/new"),
165168
}
166169
]}>
167170
<div className="flex h-full p-2 mt-0.5">
@@ -179,13 +182,13 @@ export default function Menu() {
179182
}
180183

181184
return <>
182-
<header className="lg:px-28 px-10 flex flex-col pt-4 space-y-4">
183-
<div className="flex">
185+
<header className={`lg:px-28 px-10 flex flex-col pt-4 space-y-4 ${isMinimalUI ? 'pb-4' : ''}`}>
186+
<div className="flex h-10">
184187
<div className="flex justify-between items-center pr-3">
185188
<Link to="/">
186189
<img src={gitpodIcon} className="h-6" />
187190
</Link>
188-
<div className="ml-2 text-base">
191+
{!isMinimalUI && <div className="ml-2 text-base">
189192
{showTeamsUI
190193
? renderTeamMenu()
191194
: <nav className="flex-1">
@@ -197,13 +200,13 @@ export default function Menu() {
197200
</ul>
198201
</nav>
199202
}
200-
</div>
203+
</div>}
201204
</div>
202205
<div className="flex flex-1 items-center w-auto" id="menu">
203206
<nav className="flex-1">
204207
<ul className="flex flex-1 items-center justify-between text-base text-gray-700 space-x-2">
205208
<li className="flex-1"></li>
206-
{rightMenu.map(entry => <li key={entry.title}>
209+
{!isMinimalUI && rightMenu.map(entry => <li key={entry.title}>
207210
<PillMenuItem name={entry.title} selected={isSelected(entry, location)} link={entry.link}/>
208211
</li>)}
209212
</ul>
@@ -230,7 +233,7 @@ export default function Menu() {
230233
</div>
231234
</div>
232235
</div>
233-
{showTeamsUI && <div className="flex">
236+
{!isMinimalUI && showTeamsUI && <div className="flex">
234237
{leftMenu.map(entry => <TabMenuItem name={entry.title} selected={isSelected(entry, location)} link={entry.link}/>)}
235238
</div>}
236239
</header>

components/dashboard/src/components/ItemsList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export function Item(props: {
2222
}) {
2323
const headerClassName = "text-sm text-gray-400 border-t border-b border-gray-200 dark:border-gray-800";
2424
const notHeaderClassName = "rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gitpod-kumquat-light";
25-
return <div className={`flex flex-grow flex-row w-full px-3 py-3 justify-between transition ease-in-out group ${props.header ? headerClassName : notHeaderClassName} ${props.className || ""}`}>
25+
return <div className={`flex flex-grow flex-row w-full px-3 py-3 justify-between transition ease-in-out ${props.header ? headerClassName : notHeaderClassName} ${props.className || ""}`}>
2626
{props.children}
2727
</div>;
2828
}

components/dashboard/src/components/Tooltip.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function Tooltip(props: TooltipProps) {
2020
{props.children}
2121
</div>
2222
{expanded ?
23-
<div style={{top: '-100%', left: '50%', transform: 'translate(-50%, -100%)'}} className={`mt-2 z-50 py-1 px-2 bg-gray-900 text-gray-100 text-sm absolute flex flex-col border border-gray-200 dark:border-gray-800 rounded-md truncated`}>
23+
<div style={{top: '-100%', left: '50%', transform: 'translate(-50%, -100%)'}} className={`mt-2 z-50 py-1 px-2 bg-gray-900 text-gray-100 text-sm absolute flex flex-col border border-gray-200 dark:border-gray-800 rounded-md truncated whitespace-nowrap`}>
2424
{props.content}
2525
</div>
2626
:

components/dashboard/src/projects/NewProject.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ export default function NewProject() {
305305
</>)
306306
};
307307

308-
return (<div className="flex flex-col w-96 mt-16 mx-auto items-center">
308+
return (<div className="flex flex-col w-96 mt-24 mx-auto items-center">
309309
<h1>New Project</h1>
310310
<p className="text-gray-500 text-center text-base">Projects allow you to set up and acess Prebuilds.</p>
311311

components/dashboard/src/teams/Members.tsx

Lines changed: 97 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,32 @@
44
* See License-AGPL.txt in the project root for license information.
55
*/
66

7-
import { TeamMemberInfo, TeamMembershipInvite } from "@gitpod/gitpod-protocol";
7+
import { TeamMemberInfo, TeamMemberRole, TeamMembershipInvite } from "@gitpod/gitpod-protocol";
88
import moment from "moment";
99
import { useContext, useEffect, useState } from "react";
10-
import { useLocation } from "react-router";
10+
import { useHistory, useLocation } from "react-router";
1111
import Header from "../components/Header";
1212
import DropDown from "../components/DropDown";
1313
import { ItemsList, Item, ItemField, ItemFieldContextMenu } from "../components/ItemsList";
1414
import Modal from "../components/Modal";
15-
import { getGitpodService } from "../service/service";
15+
import Tooltip from "../components/Tooltip";
1616
import copy from '../images/copy.svg';
17+
import { getGitpodService } from "../service/service";
18+
import { UserContext } from "../user-context";
1719
import { TeamsContext, getCurrentTeam } from "./teams-context";
1820

1921

2022
export default function() {
21-
const { teams } = useContext(TeamsContext);
23+
const { user } = useContext(UserContext);
24+
const { teams, setTeams } = useContext(TeamsContext);
25+
const history = useHistory();
2226
const location = useLocation();
2327
const team = getCurrentTeam(location, teams);
2428
const [ members, setMembers ] = useState<TeamMemberInfo[]>([]);
2529
const [ genericInvite, setGenericInvite ] = useState<TeamMembershipInvite>();
2630
const [ showInviteModal, setShowInviteModal ] = useState<boolean>(false);
31+
const [ searchText, setSearchText ] = useState<string>('');
32+
const [ roleFilter, setRoleFilter ] = useState<TeamMemberRole | undefined>();
2733

2834
useEffect(() => {
2935
if (!team) {
@@ -32,16 +38,18 @@ export default function() {
3238
(async () => {
3339
const [infos, invite] = await Promise.all([
3440
getGitpodService().server.getTeamMembers(team.id),
35-
getGitpodService().server.getGenericInvite(team.id)]);
36-
41+
getGitpodService().server.getGenericInvite(team.id),
42+
]);
3743
setMembers(infos);
3844
setGenericInvite(invite);
3945
})();
4046
}, [ team ]);
4147

48+
const ownMemberInfo = members.find(m => m.userId === user?.id);
49+
4250
const getInviteURL = (inviteId: string) => {
4351
const link = new URL(window.location.href);
44-
link.pathname = '/join-team';
52+
link.pathname = '/teams/join';
4553
link.search = '?inviteId=' + inviteId;
4654
return link.href;
4755
}
@@ -70,6 +78,36 @@ export default function() {
7078
}
7179
}
7280

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+
73111
return <>
74112
<Header title="Members" subtitle="Manage team members." />
75113
<div className="lg:px-28 px-10">
@@ -78,19 +116,19 @@ export default function() {
78116
<div className="py-4">
79117
<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>
80118
</div>
81-
<input type="search" placeholder="Search Members" onChange={() => { /* TODO */ }} />
119+
<input type="search" placeholder="Search Members" onChange={e => setSearchText(e.target.value)} />
82120
</div>
83121
<div className="flex-1" />
84122
<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={[{
86124
title: 'All',
87-
onClick: () => { /* TODO */ }
125+
onClick: () => setRoleFilter(undefined)
88126
}, {
89127
title: 'Owner',
90-
onClick: () => { /* TODO */ }
128+
onClick: () => setRoleFilter('owner')
91129
}, {
92130
title: 'Member',
93-
onClick: () => { /* TODO */ }
131+
onClick: () => setRoleFilter('member')
94132
}]} />
95133
</div>
96134
<button onClick={() => setShowInviteModal(true)} className="ml-2">Invite Members</button>
@@ -100,36 +138,54 @@ export default function() {
100138
<ItemField>
101139
<span className="pl-14">Name</span>
102140
</ItemField>
103-
<ItemField>
141+
<ItemField className="flex items-center space-x-1">
104142
<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>
105144
</ItemField>
106145
<ItemField className="flex items-center">
107146
<span className="flex-grow">Role</span>
108147
<ItemFieldContextMenu />
109148
</ItemField>
110149
</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>)}
133189
</ItemsList>
134190
</div>
135191
{genericInvite && showInviteModal && <Modal visible={true} onClose={() => setShowInviteModal(false)}>
@@ -139,10 +195,14 @@ export default function() {
139195
<div className="w-full relative">
140196
<input name="inviteUrl" disabled={true} readOnly={true} type="text" value={getInviteURL(genericInvite.id)} className="rounded-md w-full truncate pr-8" />
141197
<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>
143203
</div>
144204
</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>
146206
</div>
147207
<div className="flex justify-end mt-6 space-x-2">
148208
<button className="secondary" onClick={() => resetInviteLink()}>Reset Invite Link</button>

components/dashboard/src/teams/NewTeam.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export default function () {
2727
setCreationError(error);
2828
}
2929
}
30-
return <div className="flex flex-col w-96 mt-16 mx-auto items-center">
30+
return <div className="flex flex-col w-96 mt-24 mx-auto items-center">
3131
<h1>New Team</h1>
3232
<p className="text-gray-500 text-center text-base">Teams allow you to <strong>group multiple projects</strong>, <strong>collaborate with others</strong>, <strong>manage subscriptions</strong> with one centralized billing, and more. <a className="learn-more" href="https://www.gitpod.io/docs/teams/">Learn more</a></p>
3333
<form className="mt-16 w-full" onSubmit={createTeam}>

0 commit comments

Comments
 (0)