Skip to content
124 linesCodeBlameRaw
1import { Lock, Plus, Search } from "lucide-react";
2import { data, useSearchParams, type ShouldRevalidateFunctionArgs } from "react-router";
3
4import { type Team, mayCreateTeams } from "@g1t/contracts";
5
6import type { Route } from "./+types/teams";
7import { page } from "../../lib/meta";
8import { TeamRow } from "../../components/teams";
9import { ButtonLink, EmptyState, notACredential } from "../../components/ui";
10import { filterTeams, splitTeams } from "../../lib/teams";
11import { agentIdsOn } from "../../lib/people";
12import { identity, workspaceAgents } from "../../lib/services.server";
13import { getViewer, roleIn, unwrap } from "../../lib/session.server";
14
15export function meta({ params, ...args }: Route.MetaArgs) {
16 return page(args, { title: `Teams · ${params.owner} · g1t` });
17}
18
19export async function loader({ params, context }: Route.LoaderArgs) {
20 const viewer = getViewer(context);
21 // A workspace's teams are its members' business.
22 const role = roleIn(viewer, params.owner);
23 if (!role) throw data(null, { status: 404 });
24 const [teams, workspace, directory, agents] = await Promise.all([
25 identity.listTeams(viewer, params.owner).then(unwrap),
26 identity.getWorkspace(params.owner).catch(() => null),
27 // Which agents are on each team: added to it, or their home team.
28 identity.peopleDirectory(viewer, params.owner).catch(() => null),
29 workspaceAgents.list(params.owner, viewer!).catch(() => null),
30 ]);
31 const listed = agents?.ok ? agents.value : [];
32 const agentCounts = Object.fromEntries(
33 (directory?.ok ? directory.value.teams : []).map((team) => [team.slug, agentIdsOn(team, listed).length]),
34 );
35 // Who may create one is the workspace's to say (its settings).
36 return { teams, agentCounts, slug: params.owner.toLowerCase(), canCreate: mayCreateTeams(workspace?.teamCreation, role) };
37}
38
39/** Searching changes only the address: the list is already here. */
40export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
41 if (!formMethod && currentUrl.pathname === nextUrl.pathname) return false;
42 return defaultShouldRevalidate;
43}
44
45export default function WorkspaceTeams({ loaderData }: Route.ComponentProps) {
46 const { teams, agentCounts, slug, canCreate } = loaderData;
47 const [params, setParams] = useSearchParams();
48 const query = params.get("q") ?? "";
49 const { mine, others } = splitTeams(filterTeams(teams, query));
50 const setQuery = (value: string) =>
51 setParams(
52 (current) => {
53 const next = new URLSearchParams(current);
54 if (value) next.set("q", value);
55 else next.delete("q");
56 return next;
57 },
58 { replace: true, preventScrollReset: true },
59 );
60
61 return (
62 <div className="space-y-8">
63 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
64 <label className="relative block sm:w-72">
65 <span className="sr-only">Find a team</span>
66 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
67 <input
68 type="search"
69 value={query}
70 onChange={(event) => setQuery(event.target.value)}
71 placeholder="Find a team"
72 {...notACredential()}
73 className="w-full rounded-md border border-line bg-bg py-1.5 pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
74 />
75 </label>
76 {canCreate ? (
77 <ButtonLink to={`/${slug}/-/teams/new`}>
78 <Plus size={15} />
79 New team
80 </ButtonLink>
81 ) : (
82 <p className="flex items-center gap-1.5 text-xs text-muted">
83 <Lock size={13} className="shrink-0 text-faint" />
84 Only owners can create teams in this workspace.
85 </p>
86 )}
87 </div>
88
89 {teams.length === 0 ? (
90 <EmptyState title="No teams yet">
91 A team brings people and agents together: a lead, a channel, roles on repositories and a budget for its agents.
92 Mention it as <span className="font-mono text-fg">@{slug}/team</span>, or ask it to review a pull request.
93 </EmptyState>
94 ) : mine.length + others.length === 0 ? (
95 <p className="rounded-xl border border-dashed border-line px-4 py-8 text-center text-sm text-muted">
96 No teams match.{" "}
97 <button type="button" className="text-accent hover:underline" onClick={() => setQuery("")}>
98 Show them all
99 </button>
100 </p>
101 ) : (
102 <>
103 {mine.length > 0 && <TeamList title="Your teams" teams={mine} agents={agentCounts} />}
104 {others.length > 0 && <TeamList title={mine.length > 0 ? "Other teams" : "All teams"} teams={others} agents={agentCounts} />}
105 </>
106 )}
107 </div>
108 );
109}
110
111function TeamList({ title, teams, agents }: { title: string; teams: Team[]; agents: Record<string, number> }) {
112 return (
113 <section aria-label={title}>
114 <h2 className="mb-3 text-sm font-medium text-muted">
115 {title} <span className="text-faint">{teams.length}</span>
116 </h2>
117 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
118 {teams.map((team) => (
119 <TeamRow key={team.id} team={team} agents={agents[team.slug]} />
120 ))}
121 </ul>
122 </section>
123 );
124}