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