Skip to content

Commit 348b35d

Browse files
author
Laurie T. Malau
committed
Allow teams serch
1 parent 343ae26 commit 348b35d

File tree

12 files changed

+306
-4
lines changed

12 files changed

+306
-4
lines changed

components/dashboard/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const UserSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/
5959
const WorkspacesSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/WorkspacesSearch'));
6060
const AdminSettings = React.lazy(() => import(/* webpackPrefetch: true */ './admin/Settings'));
6161
const ProjectsSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/ProjectsSearch'));
62+
const TeamsSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/TeamsSearch'));
6263
const OAuthClientApproval = React.lazy(() => import(/* webpackPrefetch: true */ './OauthClientApproval'));
6364

6465
function Loading() {
@@ -298,6 +299,7 @@ function App() {
298299
<Route path="/from-referrer" exact component={FromReferrer} />
299300

300301
<Route path="/admin/users" component={UserSearch} />
302+
<Route path="/admin/teams" component={TeamsSearch} />
301303
<Route path="/admin/workspaces" component={WorkspacesSearch} />
302304
<Route path="/admin/settings" component={AdminSettings} />
303305
<Route path="/admin/projects" component={ProjectsSearch} />

components/dashboard/src/Menu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export default function Menu() {
4141
const match = useRouteMatch<{ segment1?: string, segment2?: string, segment3?: string }>("/(t/)?:segment1/:segment2?/:segment3?");
4242
const projectSlug = (() => {
4343
const resource = match?.params?.segment2;
44-
if (resource && !["projects", "members", "users", "workspaces", "settings"].includes(resource)) {
44+
if (resource && !["projects", "members", "users", "workspaces", "settings", "teams"].includes(resource)) {
4545
return resource;
4646
}
4747
})();
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Copyright (c) 2022 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 moment from "moment";
8+
import { useEffect, useState } from "react";
9+
import { Team, TeamMemberInfo, TeamMemberRole } from "@gitpod/gitpod-protocol";
10+
import { getGitpodService } from "../service/service";
11+
import { Item, ItemField, ItemsList } from "../components/ItemsList";
12+
import PillLabel from "../components/PillLabel";
13+
import DropDown from "../components/DropDown";
14+
import { Link } from "react-router-dom";
15+
16+
export default function TeamDetail(props: { team: Team }) {
17+
const { team } = props;
18+
const [teamMembers, setTeamMembers] = useState<TeamMemberInfo[] | undefined>(undefined);
19+
const [searchText, setSearchText] = useState<string>('');
20+
21+
useEffect(() => {
22+
(async () => {
23+
const members = await getGitpodService().server.adminGetTeamMembers(team.id);
24+
if (members.length > 0) {
25+
setTeamMembers(members)
26+
}
27+
})();
28+
}, [team]);
29+
30+
const filteredMembers = teamMembers?.filter(m => {
31+
const memberSearchText = `${m.fullName || ''}${m.primaryEmail || ''}`.toLocaleLowerCase();
32+
if (!memberSearchText.includes(searchText.toLocaleLowerCase())) {
33+
return false;
34+
}
35+
return true;
36+
});
37+
38+
const setTeamMemberRole = async (userId: string, role: TeamMemberRole) => {
39+
await getGitpodService().server.adminSetTeamMemberRole(team!.id, userId, role);
40+
setTeamMembers(await getGitpodService().server.adminGetTeamMembers(team!.id));
41+
}
42+
return <>
43+
<div className="flex">
44+
<div className="flex-1">
45+
<div className="flex"><h3>{team.name}</h3>
46+
{team.markedDeleted && <span className="mt-2"><PillLabel type="warn" className="font-semibold mt-2 py-0.5 px-2 self-center">Deleted</PillLabel></span>}
47+
</div>
48+
<p className="mb-6">/t/{team.slug}</p>
49+
{!team.markedDeleted && <>
50+
<span className="text-gray-400">{teamMembers && teamMembers.length} member(s)</span>
51+
<span className="text-gray-400"> · </span>
52+
</>}
53+
<span className="text-gray-400">Created on {moment(team.creationTime).format('MMM D, YYYY')}</span>
54+
</div>
55+
</div>
56+
<div className="flex mt-4">
57+
<div className="flex">
58+
<div className="py-4">
59+
<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>
60+
</div>
61+
<input type="search" placeholder="Search Members" onChange={e => setSearchText(e.target.value)} />
62+
</div>
63+
</div>
64+
65+
<ItemsList className="mt-2">
66+
{!team.markedDeleted && <Item header={true} className="grid grid-cols-3">
67+
<ItemField className="my-auto">
68+
<span className="pl-14">Name</span>
69+
</ItemField>
70+
<ItemField className="flex items-center space-x-1 my-auto">
71+
<span>Joined</span>
72+
<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>
73+
</ItemField>
74+
<ItemField className="flex items-center my-auto">
75+
<span className="flex-grow">Role</span>
76+
</ItemField>
77+
</Item>}
78+
{!team.markedDeleted && (!filteredMembers || filteredMembers.length === 0)
79+
? <p className="pt-16 text-center">No members found</p>
80+
: filteredMembers && filteredMembers.map(m => <Item className="grid grid-cols-3" key={m.userId}>
81+
<ItemField className="flex items-center my-auto">
82+
<div className="w-14">{m.avatarUrl && <img className="rounded-full w-8 h-8" src={m.avatarUrl || ''} alt={m.fullName} />}</div>
83+
<Link to={"/admin/users/" + m.userId}><div>
84+
<div className="text-base text-gray-900 dark:text-gray-50 font-medium">{m.fullName}</div>
85+
<p>{m.primaryEmail}</p>
86+
</div></Link>
87+
</ItemField>
88+
<ItemField className="my-auto">
89+
<span className="text-gray-400">{moment(m.memberSince).fromNow()}</span>
90+
</ItemField>
91+
<ItemField className="flex items-center my-auto">
92+
<span className="text-gray-400 capitalize">
93+
<DropDown contextMenuWidth="w-32" activeEntry={m.role} entries={[{
94+
title: 'owner',
95+
onClick: () => setTeamMemberRole(m.userId, 'owner')
96+
}, {
97+
title: 'member',
98+
onClick: () => setTeamMemberRole(m.userId, 'member')
99+
}]} />
100+
</span>
101+
</ItemField>
102+
</Item>)}
103+
</ItemsList>
104+
</>
105+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Copyright (c) 2022 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 moment from "moment";
8+
import { useState, useContext, useEffect } from "react";
9+
10+
import TeamDetail from "./TeamDetail";
11+
import { adminMenu } from "./admin-menu";
12+
import { useLocation } from "react-router";
13+
import { Link, Redirect } from "react-router-dom";
14+
import { UserContext } from "../user-context";
15+
import { getGitpodService } from "../service/service";
16+
import { PageWithSubMenu } from "../components/PageWithSubMenu";
17+
import { AdminGetListResult, Team } from "@gitpod/gitpod-protocol";
18+
import PillLabel from "../components/PillLabel";
19+
20+
export default function TeamsSearchPage() {
21+
return (
22+
<PageWithSubMenu subMenu={adminMenu} title="Teams" subtitle="Search and manage teams.">
23+
<TeamsSearch />
24+
</PageWithSubMenu>
25+
)
26+
}
27+
28+
export function TeamsSearch() {
29+
const location = useLocation();
30+
const { user } = useContext(UserContext);
31+
const [searching, setSearching] = useState(false);
32+
const [searchTerm, setSearchTerm] = useState('');
33+
const [currentTeam, setCurrentTeam] = useState<Team | undefined>(undefined);
34+
const [searchResult, setSearchResult] = useState<AdminGetListResult<Team>>({ total: 0, rows: [] });
35+
36+
useEffect(() => {
37+
const teamId = location.pathname.split('/')[3];
38+
if (teamId && searchResult) {
39+
let foundTeam = searchResult.rows.find(team => team.id === teamId);
40+
if (foundTeam) {
41+
setCurrentTeam(foundTeam);
42+
} else {
43+
getGitpodService().server.adminGetTeamById(teamId)
44+
.then(team => setCurrentTeam(team))
45+
.catch(e => console.error(e));
46+
}
47+
} else {
48+
setCurrentTeam(undefined);
49+
}
50+
}, [location]);
51+
52+
if (!user || !user?.rolesOrPermissions?.includes('admin')) {
53+
return <Redirect to="/"/>
54+
}
55+
56+
if (currentTeam) {
57+
return <TeamDetail team={currentTeam} />;
58+
}
59+
60+
const search = async () => {
61+
setSearching(true);
62+
try {
63+
const result = await getGitpodService().server.adminGetTeams({
64+
searchTerm,
65+
limit: 100,
66+
orderBy: 'creationTime',
67+
offset: 0,
68+
orderDir: "desc"
69+
})
70+
setSearchResult(result);
71+
} finally {
72+
setSearching(false);
73+
}
74+
}
75+
return <>
76+
<div className="pt-8 flex">
77+
<div className="flex justify-between w-full">
78+
<div className="flex">
79+
<div className="py-4">
80+
<svg className={searching ? 'animate-spin' : ''} width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg">
81+
<path fillRule="evenodd" clipRule="evenodd" 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" fill="#A8A29E" />
82+
</svg>
83+
</div>
84+
<input type="search" placeholder="Search Teams" onKeyDown={(k) => k.key === 'Enter' && search()} onChange={(v) => { setSearchTerm(v.target.value) }} />
85+
</div>
86+
<button disabled={searching} onClick={search}>Search</button>
87+
</div>
88+
</div>
89+
<div className="flex flex-col space-y-2">
90+
<div className="px-6 py-3 flex justify-between text-sm text-gray-400 border-t border-b border-gray-200 dark:border-gray-800 mb-2">
91+
<div className="w-7/12">Name</div>
92+
<div className="w-5/12 flex items-center"><span>Created</span>
93+
<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>
94+
</div>
95+
96+
97+
</div>
98+
{searchResult.rows.map(team => <TeamResultItem team={team} />)}
99+
</div>
100+
</>
101+
102+
function TeamResultItem(props: { team: Team }) {
103+
return (
104+
<Link key={'pr-' + props.team.name} to={'/admin/teams/' + props.team.id} data-analytics='{"button_type":"sidebar_menu"}'>
105+
<div className="rounded-xl whitespace-nowrap flex py-6 px-6 w-full justify-between hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gitpod-kumquat-light group">
106+
<div className="flex flex-col w-7/12 truncate">
107+
<div className="font-medium text-gray-800 dark:text-gray-100 truncate">{props.team.name}
108+
{props.team.markedDeleted && <span><PillLabel type="warn" className="font-semibold mt-2 py-0.5 px-2 self-center">Deleted</PillLabel></span>}
109+
</div>
110+
</div>
111+
<div className="flex w-5/12 self-center">
112+
<div className="text-sm w-full text-gray-400 truncate">{moment(props.team.creationTime).format('MMM D, YYYY')}</div>
113+
</div>
114+
</div>
115+
</Link>
116+
)
117+
}
118+
}

components/dashboard/src/admin/admin-menu.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export const adminMenu = [
99
title: 'Users',
1010
link: ['/admin/users', '/admin']
1111
},
12+
{
13+
title: 'Teams',
14+
link: ['/admin/teams']
15+
},
1216
{
1317
title: 'Workspaces',
1418
link: ['/admin/workspaces']

components/gitpod-db/src/team-db.spec.db.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,17 @@ import { DBIdentity } from './typeorm/entity/db-identity';
112112

113113
expect(teams.length).to.eq(0);
114114
}
115+
116+
@test(timeout(10000))
117+
public async findTeams() {
118+
const user = await this.userDb.newUser();
119+
await this.db.createTeam(user.id, 'First Team');
120+
await this.db.createTeam(user.id, 'Second Team');
121+
122+
const searchTerm = 'first';
123+
const result = await this.db.findTeams(0, 10, "creationTime", "DESC", searchTerm);
124+
expect(result.rows.length).to.eq(1);
125+
}
115126
}
116127

117128
module.exports = new TeamDBSpec()

components/gitpod-db/src/team-db.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Team, TeamMemberInfo, TeamMemberRole, TeamMembershipInvite } from "@git
88

99
export const TeamDB = Symbol('TeamDB');
1010
export interface TeamDB {
11+
findTeams(offset: number, limit: number, orderBy: keyof Team, orderDir: "ASC" | "DESC", searchTerm: string): Promise<{ total: number, rows: Team[] }>;
1112
findTeamById(teamId: string): Promise<Team | undefined>;
1213
findMembersByTeam(teamId: string): Promise<TeamMemberInfo[]>;
1314
findTeamsByUser(userId: string): Promise<Team[]>;

components/gitpod-db/src/typeorm/team-db-impl.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,25 @@ export class TeamDBImpl implements TeamDB {
4141
return (await this.getEntityManager()).getRepository<DBUser>(DBUser);
4242
}
4343

44+
public async findTeams(
45+
offset: number,
46+
limit: number,
47+
orderBy: keyof Team,
48+
orderDir: "DESC" | "ASC",
49+
searchTerm?: string
50+
): Promise<{ total: number, rows: Team[] }> {
51+
52+
const teamRepo = await this.getTeamRepo();
53+
const queryBuilder = teamRepo.createQueryBuilder('team')
54+
.where("team.name LIKE :searchTerm", { searchTerm: `%${searchTerm}%` })
55+
.skip(offset)
56+
.take(limit)
57+
.orderBy(orderBy, orderDir)
58+
59+
const [rows, total] = await queryBuilder.getManyAndCount();
60+
return { total, rows };
61+
}
62+
4463
public async findTeamById(teamId: string): Promise<Team | undefined> {
4564
const teamRepo = await this.getTeamRepo();
4665
return teamRepo.findOne({ id: teamId, deleted: false, markedDeleted: false});

components/gitpod-protocol/src/admin-protocol.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66

77
import { User, Workspace, NamedWorkspaceFeatureFlag } from "./protocol";
88
import { FindPrebuildsParams } from "./gitpod-service";
9-
import { PrebuildWithStatus } from "./teams-projects-protocol"
10-
import { Project, Team } from "./teams-projects-protocol";
9+
import { Project, Team, PrebuildWithStatus, TeamMemberInfo, TeamMemberRole } from "./teams-projects-protocol"
1110
import { WorkspaceInstance, WorkspaceInstancePhase } from "./workspace-instance";
1211
import { RoleOrPermission } from "./permission";
1312
import { AccountStatement } from "./accounting-protocol";
@@ -21,7 +20,10 @@ export interface AdminServer {
2120
adminModifyRoleOrPermission(req: AdminModifyRoleOrPermissionRequest): Promise<User>;
2221
adminModifyPermanentWorkspaceFeatureFlag(req: AdminModifyPermanentWorkspaceFeatureFlagRequest): Promise<User>;
2322

23+
adminGetTeamMembers(teamId: string): Promise<TeamMemberInfo[]>;
24+
adminGetTeams(req: AdminGetListRequest<Team>): Promise<AdminGetListResult<Team>>;
2425
adminGetTeamById(id: string): Promise<Team | undefined>;
26+
adminSetTeamMemberRole(teamId: string, userId: string, role: TeamMemberRole): Promise<void>;
2527

2628
adminGetWorkspaces(req: AdminGetWorkspacesRequest): Promise<AdminGetListResult<WorkspaceAndInstance>>;
2729
adminGetWorkspace(id: string): Promise<WorkspaceAndInstance>;

0 commit comments

Comments
 (0)