Skip to content
141 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 aria-label="Icon (an emoji)" value={v.icon} onChange={(e) => set("icon", e.target.value)} placeholder="📚" maxLength={8} className={`${FIELD} w-14 text-center`} />
83 <input aria-label="Name" required value={v.name} onChange={(e) => set("name", e.target.value)} placeholder="Engineering" maxLength={80} className={FIELD} />
84 </div>
85 </Row>
86 <Row label="Description" hint="One line on what belongs here.">
87 <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} />
88 </Row>
89 <Row label="Address" hint="In the space's links. Left empty, it comes from the name.">
90 <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`} />
91 </Row>
92 <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."}>
93 <SelectField
94 aria-label="Who it's for"
95 value={v.kind}
96 disabled={isDefault}
97 onValueChange={(kind) => set("kind", kind as DocSpaceKind)}
98 options={(["workspace", "team", "private"] as DocSpaceKind[]).filter((k) => k !== "team" || teams.length > 0).map((k) => ({ value: k, label: DOC_SPACE_KIND_LABELS[k] }))}
99 className="h-9 w-full"
100 />
101 </Row>
102 {v.kind === "team" && (
103 <Row label="Team">
104 <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" />
105 </Row>
106 )}
107 {v.kind !== "private" && (
108 <Row label={v.kind === "team" ? "The team can" : "Everyone can"} hint="Members you add below can be given more.">
109 <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" />
110 </Row>
111 )}
112 <Row label="Projects" hint="owner/name, comma separated. Artifacts can be filtered by project.">
113 <input aria-label="Projects" value={v.projects} onChange={(e) => set("projects", e.target.value)} placeholder="acme/web, acme/api" className={`${FIELD} font-mono`} />
114 </Row>
115 {error && <ErrorText>{error}</ErrorText>}
116 <div className="flex justify-end">
117 <Button type="submit" variant="accent" disabled={busy}>
118 {busy ? "Saving…" : submitLabel}
119 </Button>
120 </div>
121 </form>
122 );
123}
124
125/** The form's value as the service takes it. */
126export function spaceInput(v: SpaceFormValue) {
127 return {
128 name: v.name.trim(),
129 icon: v.icon.trim() || null,
130 slug: v.slug.trim() || null,
131 description: v.description.trim() || null,
132 kind: v.kind,
133 team: v.kind === "team" ? v.team : null,
134 default_role: v.kind === "private" ? null : v.default_role,
135 agent_mode: v.agent_mode,
136 projects: v.projects
137 .split(/[\s,]+/)
138 .map((p) => p.trim())
139 .filter(Boolean),
140 };
141}