Skip to content
153 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 * how agents change what is in it, and the projects it is about.
5 */
6import { DOC_AGENT_MODE_LABELS, DOC_ROLE_LABELS, DOC_SPACE_KIND_LABELS, type DocAgentMode, type DocRole, type DocSpace, type DocSpaceKind } from "@g1t/contracts";
7import { type FormEvent, useState } from "react";
8
9import { ErrorText } from "../ui";
10import { Button } from "../ui/button";
11import { SelectField } from "../ui/select";
12
13export type SpaceFormValue = {
14 name: string;
15 icon: string;
16 slug: string;
17 description: string;
18 kind: DocSpaceKind;
19 team: string;
20 default_role: DocRole;
21 agent_mode: DocAgentMode;
22 projects: string;
23};
24
25export function initialSpace(space?: DocSpace | null): SpaceFormValue {
26 return {
27 name: space?.name ?? "",
28 icon: space?.icon ?? "",
29 slug: space?.slug ?? "",
30 description: space?.description ?? "",
31 kind: space?.kind ?? "workspace",
32 team: space?.team ?? "",
33 default_role: space?.default_role ?? "edit",
34 agent_mode: space?.agent_mode ?? "suggest",
35 projects: (space?.projects ?? []).join(", "),
36 };
37}
38
39const 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";
40
41function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
42 return (
43 <div className="grid gap-1.5 sm:grid-cols-[12rem_minmax(0,1fr)] sm:gap-6">
44 <div>
45 <div className="text-sm font-medium">{label}</div>
46 {hint && <p className="mt-0.5 text-xs leading-relaxed text-faint">{hint}</p>}
47 </div>
48 <div className="min-w-0">{children}</div>
49 </div>
50 );
51}
52
53export function SpaceForm({
54 initial,
55 teams,
56 isDefault = false,
57 submitLabel,
58 busy,
59 error,
60 onSubmit,
61}: {
62 initial: SpaceFormValue;
63 teams: { slug: string; name: string }[];
64 isDefault?: boolean;
65 submitLabel: string;
66 busy: boolean;
67 error: string | null;
68 onSubmit: (value: SpaceFormValue) => void;
69}) {
70 const [v, setV] = useState(initial);
71 const set = <K extends keyof SpaceFormValue>(key: K, value: SpaceFormValue[K]) => setV((was) => ({ ...was, [key]: value }));
72 const submit = (e: FormEvent) => {
73 e.preventDefault();
74 onSubmit(v);
75 };
76 const roles = (["view", "comment", "edit"] as DocRole[]).map((r) => ({ value: r, label: DOC_ROLE_LABELS[r] }));
77 return (
78 <form onSubmit={submit} className="space-y-6">
79 <Row label="Name">
80 <div className="flex gap-2">
81 <input aria-label="Icon (an emoji)" value={v.icon} onChange={(e) => set("icon", e.target.value)} placeholder="📚" maxLength={8} className={`${FIELD} w-14 text-center`} />
82 <input aria-label="Name" required value={v.name} onChange={(e) => set("name", e.target.value)} placeholder="Engineering" maxLength={80} className={FIELD} />
83 </div>
84 </Row>
85 <Row label="Description" hint="One line on what belongs here.">
86 <input aria-label="Description" value={v.description} onChange={(e) => set("description", e.target.value)} maxLength={300} placeholder="Specs, runbooks and decisions for the platform team" className={FIELD} />
87 </Row>
88 <Row label="Address" hint="In the space's links. Left empty, it comes from the name.">
89 <input aria-label="Address" value={v.slug} onChange={(e) => set("slug", e.target.value.toLowerCase())} placeholder="engineering" pattern="[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?" className={`${FIELD} font-mono`} />
90 </Row>
91 <Row label="Who it's for" hint={isDefault ? "The General space is always the whole workspace's." : "Members-only spaces are only their members'. Not even owners see them unless added."}>
92 <SelectField
93 aria-label="Who it's for"
94 value={v.kind}
95 disabled={isDefault}
96 onValueChange={(kind) => set("kind", kind as DocSpaceKind)}
97 options={(["workspace", "team", "private"] as DocSpaceKind[]).filter((k) => k !== "team" || teams.length > 0).map((k) => ({ value: k, label: DOC_SPACE_KIND_LABELS[k] }))}
98 className="h-9 w-full"
99 />
100 </Row>
101 {v.kind === "team" && (
102 <Row label="Team">
103 <SelectField aria-label="Team" value={v.team} onValueChange={(team) => set("team", team)} placeholder="Choose a team" options={teams.map((t) => ({ value: t.slug, label: t.name }))} className="h-9 w-full" />
104 </Row>
105 )}
106 {v.kind !== "private" && (
107 <Row label={v.kind === "team" ? "The team can" : "Everyone can"} hint="Members you add below can be given more.">
108 <SelectField aria-label="What they can do" value={v.default_role} onValueChange={(r) => set("default_role", r as DocRole)} options={roles} className="h-9 w-full" />
109 </Row>
110 )}
111 <Row label="Agents in this space" hint="An agent never does more than the person it works for. With Suggest, its changes wait for a person to accept them.">
112 <SelectField
113 aria-label="Agents in this space"
114 value={v.agent_mode}
115 onValueChange={(m) => set("agent_mode", m as DocAgentMode)}
116 options={(["suggest", "edit"] as DocAgentMode[]).map((m) => ({
117 value: m,
118 label: DOC_AGENT_MODE_LABELS[m],
119 description: m === "suggest" ? "Tracked changes you accept or reject (recommended)" : "Agents edit directly; every edit is in the history",
120 }))}
121 className="h-9 w-full"
122 />
123 </Row>
124 <Row label="Projects" hint="owner/name, comma separated. Artifacts can be filtered by project.">
125 <input aria-label="Projects" value={v.projects} onChange={(e) => set("projects", e.target.value)} placeholder="acme/web, acme/api" className={`${FIELD} font-mono`} />
126 </Row>
127 {error && <ErrorText>{error}</ErrorText>}
128 <div className="flex justify-end">
129 <Button type="submit" variant="accent" disabled={busy}>
130 {busy ? "Saving…" : submitLabel}
131 </Button>
132 </div>
133 </form>
134 );
135}
136
137/** The form's value as the service takes it. */
138export function spaceInput(v: SpaceFormValue) {
139 return {
140 name: v.name.trim(),
141 icon: v.icon.trim() || null,
142 slug: v.slug.trim() || null,
143 description: v.description.trim() || null,
144 kind: v.kind,
145 team: v.kind === "team" ? v.team : null,
146 default_role: v.kind === "private" ? null : v.default_role,
147 agent_mode: v.agent_mode,
148 projects: v.projects
149 .split(/[\s,]+/)
150 .map((p) => p.trim())
151 .filter(Boolean),
152 };
153}