Skip to content

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

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