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.

198 linesCodeBlameRaw
1/**
2 * A space's settings, as a new space asks for them and its settings page
3 * changes them: name, icon, address, who it is for, what they may do,
4 * and the projects it is about. Its agent mode, which only g1t's own
5 * agents read, is kept as it was.
6 */
7import { DOC_ROLE_LABELS, DOC_SPACE_KIND_LABELS, type DocAgentMode, type DocRole, type DocSpace, type DocSpaceKind } from "@g1t/contracts";
8import { type FormEvent, useState } from "react";
9
10import { ErrorText } from "../ui";
11import { Button } from "../ui/button";
12import { SelectField } from "../ui/select";
13
14export type SpaceFormValue = {
15 name: string;
16 icon: string;
17 slug: string;
18 description: string;
19 kind: DocSpaceKind;
20 team: string;
21 default_role: DocRole;
22 agent_mode: DocAgentMode;
23 projects: string;
24};
25
26export function initialSpace(space?: DocSpace | null): SpaceFormValue {
27 return {
28 name: space?.name ?? "",
29 icon: space?.icon ?? "",
30 slug: space?.slug ?? "",
31 description: space?.description ?? "",
32 kind: space?.kind ?? "workspace",
33 team: space?.team ?? "",
34 default_role: space?.default_role ?? "edit",
35 agent_mode: space?.agent_mode ?? "suggest",
36 projects: (space?.projects ?? []).join(", "),
37 };
38}
39
40const FIELD = "h-9 w-full rounded-md border border-line bg-bg px-3 text-sm text-fg outline-none placeholder:text-faint focus:border-accent/60";
41
42function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
43 return (
44 <div className="grid gap-1.5 sm:grid-cols-[12rem_minmax(0,1fr)] sm:gap-6">
45 <div>
46 <div className="text-sm font-medium">{label}</div>
47 {hint && <p className="mt-0.5 text-xs leading-relaxed text-faint">{hint}</p>}
48 </div>
49 <div className="min-w-0">{children}</div>
50 </div>
51 );
52}
53
54export function SpaceForm({
55 initial,
56 teams,
57 isDefault = false,
58 submitLabel,
59 busy,
60 error,
61 onSubmit,
62}: {
63 initial: SpaceFormValue;
64 teams: { slug: string; name: string }[];
65 isDefault?: boolean;
66 submitLabel: string;
67 busy: boolean;
68 error: string | null;
69 onSubmit: (value: SpaceFormValue) => void;
70}) {
71 const [v, setV] = useState(initial);
72 const set = <K extends keyof SpaceFormValue>(key: K, value: SpaceFormValue[K]) => setV((was) => ({ ...was, [key]: value }));
73 const submit = (e: FormEvent) => {
74 e.preventDefault();
75 onSubmit(v);
76 };
77 const roles = (["view", "comment", "edit"] as DocRole[]).map((r) => ({ value: r, label: DOC_ROLE_LABELS[r] }));
78 return (
79 <form onSubmit={submit} className="space-y-6">
80 <Row label="Name">
81 <div className="flex gap-2">
82 <input
83 aria-label="Icon (an emoji)"
84 value={v.icon}
85 onChange={(e) => set("icon", e.target.value)}
86 placeholder="📚"
87 maxLength={8}
88 className={`${FIELD} w-14 text-center`}
89 />
90 <input
91 aria-label="Name"
92 required
93 value={v.name}
94 onChange={(e) => set("name", e.target.value)}
95 placeholder="Engineering"
96 maxLength={80}
97 className={FIELD}
98 />
99 </div>
100 </Row>
101 <Row label="Description" hint="One line on what belongs here.">
102 <input
103 aria-label="Description"
104 value={v.description}
105 onChange={(e) => set("description", e.target.value)}
106 maxLength={300}
107 placeholder="Specs, runbooks and decisions for the platform team"
108 className={FIELD}
109 />
110 </Row>
111 <Row label="Address" hint="In the space's links. Left empty, it comes from the name.">
112 <input
113 aria-label="Address"
114 value={v.slug}
115 onChange={(e) => set("slug", e.target.value.toLowerCase())}
116 placeholder="engineering"
117 pattern="[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?"
118 className={`${FIELD} font-mono`}
119 />
120 </Row>
121 <Row
122 label="Who it's for"
123 hint={
124 isDefault
125 ? "The General space is always the whole workspace's."
126 : "Members-only spaces are only their members'. Not even owners see them unless added."
127 }
128 >
129 <SelectField
130 aria-label="Who it's for"
131 value={v.kind}
132 disabled={isDefault}
133 onValueChange={(kind) => set("kind", kind as DocSpaceKind)}
134 options={(["workspace", "team", "private"] as DocSpaceKind[])
135 .filter((k) => k !== "team" || teams.length > 0)
136 .map((k) => ({ value: k, label: DOC_SPACE_KIND_LABELS[k] }))}
137 className="h-9 w-full"
138 />
139 </Row>
140 {v.kind === "team" && (
141 <Row label="Team">
142 <SelectField
143 aria-label="Team"
144 value={v.team}
145 onValueChange={(team) => set("team", team)}
146 placeholder="Choose a team"
147 options={teams.map((t) => ({ value: t.slug, label: t.name }))}
148 className="h-9 w-full"
149 />
150 </Row>
151 )}
152 {v.kind !== "private" && (
153 <Row label={v.kind === "team" ? "The team can" : "Everyone can"} hint="Members you add below can be given more.">
154 <SelectField
155 aria-label="What they can do"
156 value={v.default_role}
157 onValueChange={(r) => set("default_role", r as DocRole)}
158 options={roles}
159 className="h-9 w-full"
160 />
161 </Row>
162 )}
163 <Row label="Projects" hint="owner/name, comma separated. Artifacts can be filtered by project.">
164 <input
165 aria-label="Projects"
166 value={v.projects}
167 onChange={(e) => set("projects", e.target.value)}
168 placeholder="acme/web, acme/api"
169 className={`${FIELD} font-mono`}
170 />
171 </Row>
172 {error && <ErrorText>{error}</ErrorText>}
173 <div className="flex justify-end">
174 <Button type="submit" variant="accent" disabled={busy}>
175 {busy ? "Saving…" : submitLabel}
176 </Button>
177 </div>
178 </form>
179 );
180}
181
182/** The form's value as the service takes it. */
183export function spaceInput(v: SpaceFormValue) {
184 return {
185 name: v.name.trim(),
186 icon: v.icon.trim() || null,
187 slug: v.slug.trim() || null,
188 description: v.description.trim() || null,
189 kind: v.kind,
190 team: v.kind === "team" ? v.team : null,
191 default_role: v.kind === "private" ? null : v.default_role,
192 agent_mode: v.agent_mode,
193 projects: v.projects
194 .split(/[\s,]+/)
195 .map((p) => p.trim())
196 .filter(Boolean),
197 };
198}