Skip to content

g1t/apps/web/app/routes/workspace/teams.tsx

116 lines4,756 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Owners choose who can create teams1import { Lock, Plus, Search } from "lucide-react";
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2import { data, useSearchParams, type ShouldRevalidateFunctionArgs } from "react-router";
3
Owners choose who can create teams4import { type Team, mayCreateTeams } from "@g1t/contracts";
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar5
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 { identity } from "../../lib/services.server";
12import { getViewer, roleIn, unwrap } from "../../lib/session.server";
13
14export function meta({ params, ...args }: Route.MetaArgs) {
15 return page(args, { title: `Teams · ${params.owner} · g1t` });
16}
17
18export async function loader({ params, context }: Route.LoaderArgs) {
19 const viewer = getViewer(context);
20 // A workspace's teams are its members' business.
Owners choose who can create teams21 const role = roleIn(viewer, params.owner);
22 if (!role) throw data(null, { status: 404 });
23 const [teams, workspace] = await Promise.all([
24 identity.listTeams(viewer, params.owner).then(unwrap),
25 identity.getWorkspace(params.owner).catch(() => null),
26 ]);
27 // Who may create one is the workspace's to say (its settings).
28 return { teams, slug: params.owner.toLowerCase(), canCreate: mayCreateTeams(workspace?.teamCreation, role) };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar29}
30
31/** Searching changes only the address: the list is already here. */
32export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
33 if (!formMethod && currentUrl.pathname === nextUrl.pathname) return false;
34 return defaultShouldRevalidate;
35}
36
37export default function WorkspaceTeams({ loaderData }: Route.ComponentProps) {
Owners choose who can create teams38 const { teams, slug, canCreate } = loaderData;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar39 const [params, setParams] = useSearchParams();
40 const query = params.get("q") ?? "";
41 const { mine, others } = splitTeams(filterTeams(teams, query));
42 const setQuery = (value: string) =>
43 setParams(
44 (current) => {
45 const next = new URLSearchParams(current);
46 if (value) next.set("q", value);
47 else next.delete("q");
48 return next;
49 },
50 { replace: true, preventScrollReset: true },
51 );
52
53 return (
54 <div className="space-y-8">
55 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
56 <label className="relative block sm:w-72">
57 <span className="sr-only">Find a team</span>
58 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
59 <input
60 type="search"
61 value={query}
62 onChange={(event) => setQuery(event.target.value)}
63 placeholder="Find a team"
64 {...notACredential()}
65 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"
66 />
67 </label>
Owners choose who can create teams68 {canCreate ? (
69 <ButtonLink to={`/${slug}/-/teams/new`}>
70 <Plus size={15} />
71 New team
72 </ButtonLink>
73 ) : (
74 <p className="flex items-center gap-1.5 text-xs text-muted">
75 <Lock size={13} className="shrink-0 text-faint" />
76 Only owners can create teams in this workspace.
77 </p>
78 )}
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar79 </div>
80
81 {teams.length === 0 ? (
82 <EmptyState title="No teams yet">
83 A team gives a group of members a role on repositories at once. Mention it as{" "}
84 <span className="font-mono text-fg">@{slug}/team</span>, or ask it to review a pull request.
85 </EmptyState>
86 ) : mine.length + others.length === 0 ? (
87 <p className="rounded-xl border border-dashed border-line px-4 py-8 text-center text-sm text-muted">
88 No teams match.{" "}
89 <button type="button" className="text-accent hover:underline" onClick={() => setQuery("")}>
90 Show them all
91 </button>
92 </p>
93 ) : (
94 <>
95 {mine.length > 0 && <TeamList title="Your teams" teams={mine} />}
96 {others.length > 0 && <TeamList title={mine.length > 0 ? "Other teams" : "All teams"} teams={others} />}
97 </>
98 )}
99 </div>
100 );
101}
102
103function TeamList({ title, teams }: { title: string; teams: Team[] }) {
104 return (
105 <section aria-label={title}>
106 <h2 className="mb-3 text-sm font-medium text-muted">
107 {title} <span className="text-faint">{teams.length}</span>
108 </h2>
109 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
110 {teams.map((team) => (
111 <TeamRow key={team.id} team={team} />
112 ))}
113 </ul>
114 </section>
115 );
116}