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.

213 linesCodeBlameRaw
1import { AtSign, Mail, UserPlus } from "lucide-react";
2import { type ReactNode, useEffect, useId, useRef, useState } from "react";
3import { useFetcher } from "react-router";
4
5import { MAX_INVITE_MESSAGE, type PersonMatch } from "@g1t/contracts";
6
7import { ErrorText, SubmitButton } from "./ui";
8import { Avatar } from "./ui/avatar";
9import { Button } from "./ui/button";
10import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
11import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
12import { Field, FieldDescription, FieldLabel } from "./ui/field";
13import { SelectField } from "./ui/select";
14import { Textarea } from "./ui/textarea";
15import { UserCard } from "./user-card";
16import { type InviteResult, workspaceInviteCopy } from "../lib/invites";
17import { PEOPLE_SEARCH_PATH, looksLikeEmail, peopleQuery } from "../lib/people-search";
18
19/**
20 * Inviting someone to the workspace, in a dialog opened from Members and
21 * from Invitations: who (a username, a name to search by, or an email
22 * address), the role they join with, and a note for the email. Posted to
23 * `action` as `action=add` (lib/member-invitations.server.ts); on success the
24 * dialog closes and `onInvited` is told who, so the page can say so.
25 */
26export function InviteDialog({
27 name,
28 action,
29 onInvited,
30 trigger,
31}: {
32 /** The workspace's display name. */
33 name: string;
34 /** The page's address, whose action handles `add`. */
35 action: string;
36 onInvited?: (who: string) => void;
37 /** The button that opens it; the Invite button when absent. */
38 trigger?: ReactNode;
39}) {
40 const [open, setOpen] = useState(false);
41 const fetcher = useFetcher<InviteResult>();
42 const copy = workspaceInviteCopy(name);
43 const result = fetcher.state === "idle" ? fetcher.data : undefined;
44 // Sent: the dialog closes and the page says so. Each opening starts clean.
45 const told = useRef<InviteResult | null>(null);
46 useEffect(() => {
47 if (!result || told.current === result) return;
48 told.current = result;
49 if ("invited" in result) {
50 setOpen(false);
51 onInvited?.(result.invited);
52 }
53 }, [result, onInvited]);
54 const error = result && "error" in result ? result : null;
55 const messageId = useId();
56 return (
57 <Dialog open={open} onOpenChange={setOpen}>
58 <DialogTrigger asChild>
59 {trigger ?? (
60 <Button type="button">
61 <UserPlus size={15} />
62 Invite
63 </Button>
64 )}
65 </DialogTrigger>
66 <DialogContent>
67 {open && (
68 <fetcher.Form method="post" action={action} className="grid gap-5">
69 <input type="hidden" name="action" value="add" />
70 <DialogHeader>
71 <DialogTitle>{copy.heading}</DialogTitle>
72 <DialogDescription>{copy.hint}</DialogDescription>
73 </DialogHeader>
74 <WhoField />
75 <Field>
76 <FieldLabel htmlFor={`${messageId}-role`}>Role</FieldLabel>
77 <SelectField
78 id={`${messageId}-role`}
79 name="role"
80 defaultValue="member"
81 options={[
82 { value: "member", label: "Member", description: `Works in ${name} with the base permission on its repositories.` },
83 { value: "owner", label: "Owner", description: "Everything a member can, and manages the workspace itself." },
84 ]}
85 />
86 </Field>
87 <Field>
88 <FieldLabel htmlFor={messageId}>
89 Message <span className="font-normal text-faint">(optional)</span>
90 </FieldLabel>
91 <Textarea
92 id={messageId}
93 name="message"
94 maxLength={MAX_INVITE_MESSAGE}
95 rows={3}
96 placeholder={`A line for the email: why you'd like them in ${name}.`}
97 />
98 <FieldDescription>Quoted in the invitation email, under your name.</FieldDescription>
99 </Field>
100 {error && <ErrorText>{error.error}</ErrorText>}
101 <DialogFooter>
102 <DialogClose asChild>
103 <Button type="button" variant="outline">
104 Cancel
105 </Button>
106 </DialogClose>
107 <SubmitButton fetcher={fetcher} match={{ action: "add" }} pending="Inviting…">
108 <UserPlus size={15} />
109 Invite
110 </SubmitButton>
111 </DialogFooter>
112 </fetcher.Form>
113 )}
114 </DialogContent>
115 </Dialog>
116 );
117}
118
119/**
120 * Who to invite: a search box that offers people on g1t by username and
121 * name as you type, each with their picture and card, and takes an email
122 * address as written. Only what a profile shows is offered, never anyone's
123 * address. Posts what is in the box as `member`.
124 */
125function WhoField() {
126 const [text, setText] = useState("");
127 const [people, setPeople] = useState<PersonMatch[]>([]);
128 const [chosen, setChosen] = useState<string | null>(null);
129 const asked = useRef(0);
130 const id = useId();
131
132 useEffect(() => {
133 const query = chosen === text.trim().replace(/^@+/, "") ? null : peopleQuery(text);
134 if (!query) {
135 setPeople([]);
136 return;
137 }
138 const ask = ++asked.current;
139 const timer = setTimeout(() => {
140 fetch(`${PEOPLE_SEARCH_PATH}?q=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } })
141 .then((response) => (response.ok ? (response.json() as Promise<{ people?: PersonMatch[] }>) : { people: [] }))
142 .then((found) => {
143 if (ask === asked.current) setPeople(found.people ?? []);
144 })
145 .catch(() => {
146 if (ask === asked.current) setPeople([]);
147 });
148 }, 150);
149 return () => clearTimeout(timer);
150 }, [text, chosen]);
151
152 const email = looksLikeEmail(text);
153 const listed = people.length > 0 && !email;
154 return (
155 <Field>
156 <FieldLabel htmlFor={id}>Who</FieldLabel>
157 <Command
158 shouldFilter={false}
159 className="rounded-md border border-line bg-bg has-[input:focus-visible]:border-accent-dim has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-accent/25"
160 >
161 <CommandInput
162 id={id}
163 name="member"
164 required
165 maxLength={254}
166 autoComplete="off"
167 placeholder="username, name or name@example.com"
168 value={text}
169 onValueChange={(value) => {
170 setText(value);
171 setChosen(null);
172 }}
173 className="h-9"
174 />
175 {listed && (
176 <CommandList aria-label="People on g1t" className="max-h-56 border-t border-line">
177 {people.map((person) => (
178 <CommandItem
179 key={person.username}
180 value={person.username}
181 onSelect={() => {
182 setText(person.username);
183 setChosen(person.username);
184 setPeople([]);
185 }}
186 >
187 <Avatar name={person.username} image={person.avatar} size={20} />
188 <UserCard username={person.username}>
189 <span className="font-mono">{person.username}</span>
190 </UserCard>
191 {person.name && <span className="min-w-0 truncate text-muted">{person.name}</span>}
192 </CommandItem>
193 ))}
194 </CommandList>
195 )}
196 </Command>
197 <FieldDescription className="flex items-center gap-1.5">
198 {email ? (
199 <>
200 <Mail size={12} className="shrink-0" />
201 Invited by email: {text.trim()} gets the invitation, and signs up first if it has no account.
202 </>
203 ) : chosen ? (
204 <>
205 <AtSign size={12} className="shrink-0" />@{chosen} gets the invitation in their notifications and by email.
206 </>
207 ) : (
208 "People on g1t are offered as you type; an email address is sent as written."
209 )}
210 </FieldDescription>
211 </Field>
212 );
213}