Skip to content

Commit c5027f0

Browse files
committed
[dashboard] Implement Teams UI (selector, creation wizard, members page, project page)
1 parent 3e749e2 commit c5027f0

35 files changed

+589
-182
lines changed

.gitpod.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ tasks:
4242
vscode:
4343
extensions:
4444
- bajdzis.vscode-database
45+
- bradlc.vscode-tailwindcss
4546
- EditorConfig.EditorConfig
4647
- golang.go
4748
- hangxingliu.vscode-nginx-conf-hint

components/dashboard/src/App.tsx

Lines changed: 27 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,15 @@
55
*/
66

77
import React, { Suspense, useContext, useEffect, useState } from 'react';
8-
import Menu from './components/Menu';
8+
import Menu from './Menu';
99
import { BrowserRouter } from "react-router-dom";
1010
import { Redirect, Route, Switch } from "react-router";
1111

1212
import { Login } from './Login';
1313
import { UserContext } from './user-context';
14+
import { TeamsContext } from './teams/teams-context';
1415
import { getGitpodService } from './service/service';
1516
import { shouldSeeWhatsNew, WhatsNew } from './WhatsNew';
16-
import settingsMenu from './settings/settings-menu';
17-
import { User } from '@gitpod/gitpod-protocol';
18-
import { adminMenu } from './admin/admin-menu';
1917
import gitpodIcon from './icons/gitpod.svg';
2018
import { ErrorCodes } from '@gitpod/gitpod-protocol/lib/messaging/error';
2119

@@ -30,6 +28,9 @@ const Integrations = React.lazy(() => import(/* webpackPrefetch: true */ './sett
3028
const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Preferences'));
3129
const StartWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/StartWorkspace'));
3230
const CreateWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/CreateWorkspace'));
31+
const NewTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/NewTeam'));
32+
const Members = React.lazy(() => import(/* webpackPrefetch: true */ './teams/Members'));
33+
const Projects = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Projects'));
3334
const InstallGitHubApp = React.lazy(() => import(/* webpackPrefetch: true */ './prebuilds/InstallGitHubApp'));
3435
const FromReferrer = React.lazy(() => import(/* webpackPrefetch: true */ './FromReferrer'));
3536
const UserSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/UserSearch'));
@@ -42,23 +43,28 @@ function Loading() {
4243
}
4344

4445
function isGitpodIo() {
45-
return window.location.hostname === 'gitpod.io' || window.location.hostname === 'gitpod-staging.com' || window.location.hostname.endsWith('gitpod-dev.com')
46+
return window.location.hostname === 'gitpod.io' || window.location.hostname === 'gitpod-staging.com' || window.location.hostname.endsWith('gitpod-dev.com') || window.location.hostname.endsWith('gitpod-io-dev.com')
4647
}
4748

4849
function App() {
4950
const { user, setUser } = useContext(UserContext);
51+
const { teams, setTeams } = useContext(TeamsContext);
5052

51-
const [loading, setLoading] = useState<boolean>(true);
52-
const [isWhatsNewShown, setWhatsNewShown] = useState(false);
53-
const [isSetupRequired, setSetupRequired] = useState(false);
53+
const [ loading, setLoading ] = useState<boolean>(true);
54+
const [ isWhatsNewShown, setWhatsNewShown ] = useState(false);
55+
const [ isSetupRequired, setSetupRequired ] = useState(false);
5456

5557
useEffect(() => {
5658
(async () => {
5759
try {
58-
const usr = await getGitpodService().server.getLoggedInUser()
59-
setUser(usr);
60+
const [ user, teams ] = await Promise.all([
61+
getGitpodService().server.getLoggedInUser(),
62+
getGitpodService().server.getTeams(),
63+
]);
64+
setUser(user);
65+
setTeams(teams);
6066
} catch (error) {
61-
console.log(error);
67+
console.error(error);
6268
if (error && "code" in error) {
6369
if (error.code === ErrorCodes.SETUP_REQUIRED) {
6470
setSetupRequired(true);
@@ -139,7 +145,7 @@ function App() {
139145

140146
let toRender: React.ReactElement = <Route>
141147
<div className="container">
142-
{renderMenu(user)}
148+
<Menu />
143149
<Switch>
144150
<Route path="/setup" exact component={Setup} />
145151
<Route path="/workspaces" exact component={Workspaces} />
@@ -148,6 +154,7 @@ function App() {
148154
<Route path="/notifications" exact component={Notifications} />
149155
<Route path="/plans" exact component={Plans} />
150156
<Route path="/teams" exact component={Teams} />
157+
<Route path="/new-team" exact component={NewTeam} />
151158
<Route path="/variables" exact component={EnvironmentVariables} />
152159
<Route path="/preferences" exact component={Preferences} />
153160
<Route path="/install-github-app" exact component={InstallGitHubApp} />
@@ -177,6 +184,13 @@ function App() {
177184
<p className="mt-4 text-lg text-gitpod-red">{decodeURIComponent(getURLHash())}</p>
178185
</div>
179186
</Route>
187+
{(teams || []).map(team => <Route path={`/${team.slug}`}>
188+
<Route exact path={`/${team.slug}`}>
189+
<Redirect to={`/${team.slug}/projects`} />
190+
</Route>
191+
<Route exact path={`/${team.slug}/members`} component={Members} />
192+
<Route exact path={`/${team.slug}/projects`} component={Projects} />
193+
</Route>)}
180194
<Route path="*" render={
181195
(match) => {
182196

@@ -187,8 +201,7 @@ function App() {
187201
<h1 className="text-gray-500 text-3xl">404</h1>
188202
<p className="mt-4 text-lg">Page not found.</p>
189203
</div>;
190-
}
191-
}>
204+
}}>
192205
</Route>
193206
</Switch>
194207
</div>
@@ -218,41 +231,4 @@ function getURLHash() {
218231
return window.location.hash.replace(/^[#/]+/, '');
219232
}
220233

221-
const renderMenu = (user?: User) => {
222-
const left = [
223-
{
224-
title: 'Workspaces',
225-
link: '/workspaces',
226-
alternatives: ['/']
227-
},
228-
{
229-
title: 'Settings',
230-
link: '/settings',
231-
alternatives: settingsMenu.flatMap(e => e.link)
232-
}
233-
];
234-
235-
if (user && user?.rolesOrPermissions?.includes('admin')) {
236-
left.push({
237-
title: 'Admin',
238-
link: '/admin',
239-
alternatives: adminMenu.flatMap(e => e.link)
240-
});
241-
}
242-
243-
return <Menu
244-
left={left}
245-
right={[
246-
{
247-
title: 'Docs',
248-
link: 'https://www.gitpod.io/docs/',
249-
},
250-
{
251-
title: 'Community',
252-
link: 'https://community.gitpod.io/',
253-
}
254-
]}
255-
/>;
256-
}
257-
258234
export default App;

components/dashboard/src/Login.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { AuthProviderInfo } from "@gitpod/gitpod-protocol";
88
import { useContext, useEffect, useState } from "react";
99
import { UserContext } from "./user-context";
10+
import { TeamsContext } from "./teams/teams-context";
1011
import { getGitpodService } from "./service/service";
1112
import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName, getSafeURLRedirect } from "./provider-utils";
1213
import gitpod from './images/gitpod.svg';
@@ -39,10 +40,11 @@ export function hasLoggedInBefore() {
3940

4041
export function Login() {
4142
const { setUser } = useContext(UserContext);
43+
const { setTeams } = useContext(TeamsContext);
4244
const showWelcome = !hasLoggedInBefore();
4345

44-
const [authProviders, setAuthProviders] = useState<AuthProviderInfo[]>([]);
45-
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
46+
const [ authProviders, setAuthProviders ] = useState<AuthProviderInfo[]>([]);
47+
const [ errorMessage, setErrorMessage ] = useState<string | undefined>(undefined);
4648

4749
useEffect(() => {
4850
(async () => {
@@ -62,8 +64,12 @@ export function Login() {
6264

6365
const updateUser = async () => {
6466
await getGitpodService().reconnect();
65-
const user = await getGitpodService().server.getLoggedInUser();
67+
const [ user, teams ] = await Promise.all([
68+
getGitpodService().server.getLoggedInUser(),
69+
getGitpodService().server.getTeams(),
70+
]);
6671
setUser(user);
72+
setTeams(teams);
6773
markLoggedIn();
6874
}
6975

components/dashboard/src/Menu.tsx

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { User, TeamMemberInfo } from "@gitpod/gitpod-protocol";
8+
import { useContext, useEffect, useState } from "react";
9+
import { Link, useHistory } from "react-router-dom";
10+
import { useLocation } from "react-router";
11+
import { Location } from "history";
12+
import gitpodIcon from './icons/gitpod.svg';
13+
import CaretDown from "./icons/CaretDown.svg";
14+
import { getGitpodService, gitpodHostUrl } from "./service/service";
15+
import { UserContext } from "./user-context";
16+
import { TeamsContext, getCurrentTeam } from "./teams/teams-context";
17+
import settingsMenu from './settings/settings-menu';
18+
import { adminMenu } from './admin/admin-menu';
19+
import ContextMenu from "./components/ContextMenu";
20+
import Separator from "./components/Separator";
21+
import PillMenuItem from "./components/PillMenuItem";
22+
import TabMenuItem from "./components/TabMenuItem";
23+
24+
interface Entry {
25+
title: string,
26+
link: string,
27+
alternatives?: string[]
28+
}
29+
30+
function isSelected(entry: Entry, location: Location<any>) {
31+
const all = [entry.link, ...(entry.alternatives||[])];
32+
const path = location.pathname.toLowerCase();
33+
return all.some(n => n === path || n+'/' === path);
34+
}
35+
36+
export default function Menu() {
37+
const { user } = useContext(UserContext);
38+
const { teams } = useContext(TeamsContext);
39+
const history = useHistory();
40+
const location = useLocation();
41+
42+
const userFullName = user?.fullName || user?.name || '...';
43+
const showTeamsUI = user?.rolesOrPermissions?.includes('teams-and-projects') || window.location.hostname.endsWith('gitpod-dev.com') || window.location.hostname.endsWith('gitpod-io-dev.com');
44+
const team = getCurrentTeam(location, teams);
45+
46+
const [ teamMembers, setTeamMembers ] = useState<Record<string, TeamMemberInfo[]>>({});
47+
useEffect(() => {
48+
if (!showTeamsUI || !teams) {
49+
return;
50+
}
51+
(async () => {
52+
const members: Record<string, TeamMemberInfo[]> = {};
53+
await Promise.all(teams.map(async (team) => {
54+
const infos = await getGitpodService().server.getTeamMembers(team.id);
55+
members[team.id] = infos;
56+
}));
57+
setTeamMembers(members);
58+
})();
59+
}, [ teams ]);
60+
61+
const leftMenu = (!!team
62+
? [
63+
{
64+
title: 'Projects',
65+
link: `/${team.slug}/projects`,
66+
alternatives: [`/${team.slug}`]
67+
},
68+
{
69+
title: 'Members',
70+
link: `/${team.slug}/members`
71+
}
72+
]
73+
: [
74+
{
75+
title: 'Workspaces',
76+
link: '/workspaces',
77+
alternatives: ['/']
78+
},
79+
{
80+
title: 'Settings',
81+
link: '/settings',
82+
alternatives: settingsMenu.flatMap(e => e.link)
83+
}
84+
]
85+
);
86+
const rightMenu = [
87+
...(user?.rolesOrPermissions?.includes('admin') ? [{
88+
title: 'Admin',
89+
link: '/admin',
90+
alternatives: adminMenu.flatMap(e => e.link)
91+
}] : []),
92+
{
93+
title: 'Docs',
94+
link: 'https://www.gitpod.io/docs/',
95+
},
96+
{
97+
title: 'Community',
98+
link: 'https://community.gitpod.io/',
99+
}
100+
];
101+
102+
return <>
103+
<header className="lg:px-28 px-10 flex flex-col pt-4 space-y-4">
104+
<div className="flex">
105+
<div className="flex justify-between items-center pr-3">
106+
<Link to="/">
107+
<img src={gitpodIcon} className="h-6" />
108+
</Link>
109+
<div className="ml-2 text-base">
110+
{showTeamsUI
111+
? <ContextMenu classes="w-64 left-0" menuEntries={[
112+
{
113+
title: userFullName,
114+
customContent: <div className="w-full text-gray-400 flex flex-col">
115+
<span className="text-gray-800 dark:text-gray-100 text-base font-semibold">{userFullName}</span>
116+
<span className="">Personal Account</span>
117+
</div>,
118+
separator: true,
119+
onClick: () => history.push("/"),
120+
},
121+
...(teams || []).map(t => ({
122+
title: t.name,
123+
customContent: <div className="w-full text-gray-400 flex flex-col">
124+
<span className="text-gray-800 dark:text-gray-300 text-base font-semibold">{t.name}</span>
125+
<span className="">{!!teamMembers[t.id]
126+
? `${teamMembers[t.id].length} member${teamMembers[t.id].length === 1 ? '' : 's'}`
127+
: '...'
128+
}</span>
129+
</div>,
130+
separator: true,
131+
onClick: () => history.push(`/${t.slug}`),
132+
})).sort((a,b) => a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1),
133+
{
134+
title: 'Create a new team',
135+
customContent: <div className="w-full text-gray-400 flex items-center">
136+
<span className="flex-1 font-semibold">New Team</span>
137+
<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>
138+
</div>,
139+
onClick: () => history.push("/new-team"),
140+
}
141+
]}>
142+
<div className="flex p-1.5 pl-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800">
143+
<span className="text-base text-gray-600 dark:text-gray-400 font-semibold">{team?.name || userFullName}</span>
144+
<img className="m-2 filter-grayscale" src={CaretDown}/>
145+
</div>
146+
</ContextMenu>
147+
: <nav className="flex-1">
148+
<ul className="flex flex-1 items-center justify-between text-base text-gray-700 space-x-2">
149+
<li className="flex-1"></li>
150+
{leftMenu.map(entry => <li key={entry.title}>
151+
<PillMenuItem name={entry.title} selected={isSelected(entry, location)} link={entry.link}/>
152+
</li>)}
153+
</ul>
154+
</nav>
155+
}
156+
</div>
157+
</div>
158+
<div className="flex flex-1 items-center w-auto" id="menu">
159+
<nav className="flex-1">
160+
<ul className="flex flex-1 items-center justify-between text-base text-gray-700 space-x-2">
161+
<li className="flex-1"></li>
162+
{rightMenu.map(entry => <li key={entry.title}>
163+
<PillMenuItem name={entry.title} selected={isSelected(entry, location)} link={entry.link}/>
164+
</li>)}
165+
</ul>
166+
</nav>
167+
<div className="ml-3 flex items-center justify-start mb-0 pointer-cursor m-l-auto rounded-full border-2 border-transparent hover:border-gray-200 dark:hover:border-gray-700 p-0.5 font-medium">
168+
<ContextMenu menuEntries={[
169+
{
170+
title: (user && User.getPrimaryEmail(user)) || '',
171+
customFontStyle: 'text-gray-400',
172+
separator: true
173+
},
174+
{
175+
title: 'Settings',
176+
link: '/settings',
177+
separator: true
178+
},
179+
{
180+
title: 'Logout',
181+
href: gitpodHostUrl.asApiLogout().toString()
182+
},
183+
]}>
184+
<img className="rounded-full w-6 h-6" src={user?.avatarUrl || ''} alt={user?.name || 'Anonymous'} />
185+
</ContextMenu>
186+
</div>
187+
</div>
188+
</div>
189+
{showTeamsUI && <div className="flex">
190+
{leftMenu.map(entry => <TabMenuItem name={entry.title} selected={isSelected(entry, location)} link={entry.link}/>)}
191+
</div>}
192+
</header>
193+
{showTeamsUI && <Separator />}
194+
</>;
195+
}

components/dashboard/src/WhatsNew.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function shouldSeeWhatsNew(user: User): boolean {
1717
}
1818

1919
export function WhatsNew(props: { visible: boolean, onClose: () => void }) {
20-
const {user, setUser} = useContext(UserContext);
20+
const { user, setUser } = useContext(UserContext);
2121
const internalClose = async () => {
2222
if (!user) {
2323
return;

0 commit comments

Comments
 (0)