g1t/apps/web/app/routes/repo/settings-dependencies.tsx

190 lines8,442 bytesCodeBlame
1import { ArrowDownLeft, ArrowUpRight, FileCode2, Network, Trash2 } from "lucide-react";
2import { Form, Link, useNavigation } from "react-router";
3
4import type { DependencyLink } from "@g1t/contracts";
5
6import type { Route } from "./+types/settings-dependencies";
7import { page } from "../../lib/meta";
8import { RepoSettingsHeading } from "../../components/repo-settings-heading";
9import { Avatar, Button, EmptyState, ErrorText, Field, Input } from "../../components/ui";
10import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
11import { projects } from "../../lib/services.server";
12import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
13import { requireCapability, requireInsider } from "../../lib/access.server";
14
15export function meta({ params, ...args }: Route.MetaArgs) {
16 return page(args, { title: `Dependencies · ${params.owner}/${params.repo} · g1t` });
17}
18
19export async function loader({ params, context }: Route.LoaderArgs) {
20 const viewer = getViewer(context);
21 // Admins; to anyone without a role here the page does not exist.
22 await requireInsider(context, params, "manage_settings");
23 const [deps, all] = await Promise.all([
24 projects.dependencies(params.owner, params.repo, viewer),
25 projects.list(params.owner, viewer),
26 ]);
27 return { dependencies: unwrap(deps), projects: all.ok ? all.value : [] };
28}
29
30export async function action({ request, params, context }: Route.ActionArgs) {
31 assertSameOrigin(request);
32 const user = requireUser(context, request);
33 await requireCapability(context, params, "manage_settings");
34 const form = await request.formData();
35 const on = String(form.get("on") ?? "");
36 if (form.get("intent") === "remove") {
37 const done = await projects.removeDependency(user, params.owner, params.repo, on);
38 return done.ok ? { notice: `No longer depends on ${on}.` } : { error: done.error.message };
39 }
40 const added = await projects.addDependency(user, params.owner, params.repo, on, String(form.get("as") ?? "") || null);
41 return added.ok ? { notice: `Depends on ${on}.` } : { error: added.error.message };
42}
43
44function Row({ link, base, removable, busy }: { link: DependencyLink; base: string; removable: boolean; busy: boolean }) {
45 return (
46 <li className="flex items-center gap-3 px-4 py-3 text-sm">
47 <Link to={`/${base.split("/")[1]}/${link.slug}`} className="font-medium hover:underline">
48 {link.name}
49 </Link>
50 {link.as ? (
51 <code className="rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-muted">{link.as}</code>
52 ) : (
53 <span className="text-xs text-faint">no variable</span>
54 )}
55 {link.source === "file" && (
56 <span className="inline-flex items-center gap-1 text-xs text-faint" title="Declared in .g1t/project.yml">
57 <FileCode2 size={12} />
58 project.yml
59 </span>
60 )}
61 {removable && link.source === "ui" && (
62 <Form method="post" className="ml-auto">
63 <input type="hidden" name="intent" value="remove" />
64 <input type="hidden" name="on" value={link.slug} />
65 <button
66 type="submit"
67 disabled={busy}
68 aria-label={`Stop depending on ${link.name}`}
69 className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger"
70 >
71 <Trash2 size={14} />
72 </button>
73 </Form>
74 )}
75 </li>
76 );
77}
78
79export default function DependencySettings({ loaderData, actionData, params }: Route.ComponentProps) {
80 const { dependencies, projects: all } = loaderData;
81 const busy = useNavigation().state === "submitting";
82 const base = `/${params.owner}/${params.repo}`;
83 const taken = new Set([params.repo.toLowerCase(), ...dependencies.dependsOn.map((d) => d.slug)]);
84 const choices = all.filter((project) => !taken.has(project.slug));
85 return (
86 <div className="max-w-4xl">
87 <RepoSettingsHeading base={base} />
88 <header>
89 <h2 className="flex items-center gap-2 text-sm font-medium">
90 <Network size={15} className="text-accent" />
91 Dependencies
92 </h2>
93 <p className="mt-1 max-w-2xl text-sm text-muted">
94 The projects this one uses: calls their API, consumes their package. Its builds and its running app get each
95 one's address for the same environment under the variable you name, a preview pointing at the same branch's
96 preview when there is one. Agents working here are told what uses this project.{" "}
97 <a href="https://docs.g1t.sh/guides/projects/#dependencies" className="text-fg hover:underline">
98 How dependencies work
99 </a>
100 </p>
101 </header>
102
103 <div className="mt-4 min-h-6">
104 {actionData && "notice" in actionData && <p className="text-sm text-accent">{actionData.notice}</p>}
105 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
106 </div>
107
108 <section>
109 <h3 className="flex items-center gap-1.5 text-xs font-medium tracking-wide text-muted uppercase">
110 <ArrowUpRight size={13} />
111 Depends on
112 </h3>
113 <div className="mt-2">
114 {dependencies.dependsOn.length === 0 ? (
115 <EmptyState title="Uses no other project" />
116 ) : (
117 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
118 {dependencies.dependsOn.map((link) => (
119 <Row key={link.slug} link={link} base={base} removable busy={busy} />
120 ))}
121 </ul>
122 )}
123 </div>
124 {choices.length > 0 && (
125 <Form method="post" className="mt-4 grid items-end gap-3 rounded-xl border border-line bg-surface p-4 sm:grid-cols-[1fr_1fr_auto]">
126 <input type="hidden" name="intent" value="add" />
127 <Field label="Project">
128 <Select name="on" required defaultValue={choices[0].slug}>
129 <SelectTrigger aria-label="Project" className="h-auto py-2">
130 <SelectValue />
131 </SelectTrigger>
132 <SelectContent>
133 {choices.map((project) => (
134 <SelectItem
135 key={project.slug}
136 value={project.slug}
137 icon={<Avatar name={project.name} size={16} square />}
138 description={project.slug !== project.name ? project.slug : undefined}
139 >
140 {project.name}
141 </SelectItem>
142 ))}
143 </SelectContent>
144 </Select>
145 </Field>
146 <Field label="Its address as">
147 <Input name="as" placeholder="API_URL" className="font-mono" />
148 </Field>
149 <Button type="submit" disabled={busy}>
150 Add dependency
151 </Button>
152 </Form>
153 )}
154 </section>
155
156 <section className="mt-10">
157 <h3 className="flex items-center gap-1.5 text-xs font-medium tracking-wide text-muted uppercase">
158 <ArrowDownLeft size={13} />
159 Used by
160 </h3>
161 <div className="mt-2">
162 {dependencies.usedBy.length === 0 ? (
163 <EmptyState title="No project uses this one yet" />
164 ) : (
165 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
166 {dependencies.usedBy.map((link) => (
167 <Row key={link.slug} link={link} base={base} removable={false} busy={busy} />
168 ))}
169 </ul>
170 )}
171 </div>
172 </section>
173
174 <section className="mt-10 rounded-xl border border-line p-5 text-sm">
175 <h3 className="flex items-center gap-2 font-medium">
176 <FileCode2 size={15} className="text-faint" />
177 Or declare them in the code
178 </h3>
179 <p className="mt-1 text-muted">
180 A <code className="text-fg">.g1t/project.yml</code> in the project's root directory is read on every push to
181 the default branch, and its dependencies replace the ones it declared before:
182 </p>
183 <pre className="mt-3 overflow-x-auto rounded-lg border border-line bg-bg p-3 font-mono text-xs text-muted">{`dependsOn:
184 - project: api
185 as: API_URL
186 - project: ui-kit`}</pre>
187 </section>
188 </div>
189 );
190}