Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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