Skip to content

Commit 5e15f79

Browse files
committed
Add UI flow to connect gh repos
1 parent 6776fbb commit 5e15f79

File tree

2 files changed

+282
-5
lines changed
  • apps/webapp/app

2 files changed

+282
-5
lines changed

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx

Lines changed: 278 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,34 @@
11
import { conform, useForm } from "@conform-to/react";
22
import { parse } from "@conform-to/zod";
3-
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
4-
import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react";
3+
import {
4+
CheckCircleIcon,
5+
ExclamationTriangleIcon,
6+
FolderIcon,
7+
TrashIcon,
8+
LockClosedIcon,
9+
} from "@heroicons/react/20/solid";
10+
import {
11+
Form,
12+
type MetaFunction,
13+
useActionData,
14+
useLocation,
15+
useNavigation,
16+
} from "@remix-run/react";
17+
import { type LoaderFunctionArgs } from "@remix-run/router";
518
import { type ActionFunction, json } from "@remix-run/server-runtime";
19+
import { typedjson, useTypedLoaderData } from "remix-typedjson";
620
import { z } from "zod";
721
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
822
import { InlineCode } from "~/components/code/InlineCode";
23+
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
24+
import { DialogClose, DialogDescription } from "@radix-ui/react-dialog";
25+
import { OctoKitty } from "~/components/GitHubLoginButton";
926
import {
1027
MainHorizontallyCenteredContainer,
1128
PageBody,
1229
PageContainer,
1330
} from "~/components/layout/AppLayout";
14-
import { Button } from "~/components/primitives/Buttons";
31+
import { Button, LinkButton } from "~/components/primitives/Buttons";
1532
import { ClipboardField } from "~/components/primitives/ClipboardField";
1633
import { Fieldset } from "~/components/primitives/Fieldset";
1734
import { FormButtons } from "~/components/primitives/FormButtons";
@@ -26,12 +43,21 @@ import { Paragraph } from "~/components/primitives/Paragraph";
2643
import * as Property from "~/components/primitives/PropertyTable";
2744
import { SpinnerWhite } from "~/components/primitives/Spinner";
2845
import { prisma } from "~/db.server";
46+
import { useOrganization } from "~/hooks/useOrganizations";
2947
import { useProject } from "~/hooks/useProject";
3048
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
49+
import { findProjectBySlug } from "~/models/project.server";
3150
import { DeleteProjectService } from "~/services/deleteProject.server";
3251
import { logger } from "~/services/logger.server";
3352
import { requireUserId } from "~/services/session.server";
34-
import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder";
53+
import {
54+
organizationPath,
55+
v3ProjectPath,
56+
githubAppInstallPath,
57+
EnvironmentParamSchema,
58+
} from "~/utils/pathBuilder";
59+
import { useState } from "react";
60+
import { Select, SelectItem } from "~/components/primitives/Select";
3561

3662
export const meta: MetaFunction = () => {
3763
return [
@@ -41,6 +67,51 @@ export const meta: MetaFunction = () => {
4167
];
4268
};
4369

70+
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
71+
const userId = await requireUserId(request);
72+
const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params);
73+
74+
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
75+
if (!project) {
76+
throw new Response(undefined, {
77+
status: 404,
78+
statusText: "Project not found",
79+
});
80+
}
81+
82+
const githubAppInstallations = await prisma.githubAppInstallation.findMany({
83+
where: {
84+
organizationId: project.organizationId,
85+
deletedAt: null,
86+
suspendedAt: null,
87+
},
88+
select: {
89+
id: true,
90+
targetType: true,
91+
repositories: {
92+
select: {
93+
id: true,
94+
name: true,
95+
private: true,
96+
},
97+
where: {
98+
removedAt: null,
99+
},
100+
// Most installations will only have a couple of repos so loading them here should be fine.
101+
// However, there might be outlier organizations so it's best to expose the installation repos
102+
// via a resource endpoint and filter on user input.
103+
take: 100,
104+
},
105+
},
106+
take: 20,
107+
orderBy: {
108+
createdAt: "desc",
109+
},
110+
});
111+
112+
return typedjson({ githubAppInstallations });
113+
};
114+
44115
export function createSchema(
45116
constraints: {
46117
getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string };
@@ -148,9 +219,12 @@ export const action: ActionFunction = async ({ request, params }) => {
148219
};
149220

150221
export default function Page() {
222+
const { githubAppInstallations } = useTypedLoaderData<typeof loader>();
151223
const project = useProject();
224+
const organization = useOrganization();
152225
const lastSubmission = useActionData();
153226
const navigation = useNavigation();
227+
const location = useLocation();
154228

155229
const [renameForm, { projectName }] = useForm({
156230
id: "rename-project",
@@ -212,7 +286,7 @@ export default function Page() {
212286
</NavBar>
213287

214288
<PageBody>
215-
<MainHorizontallyCenteredContainer>
289+
<MainHorizontallyCenteredContainer className="md:mt-6">
216290
<div className="flex flex-col gap-6">
217291
<div>
218292
<Header2 spacing>General</Header2>
@@ -259,6 +333,41 @@ export default function Page() {
259333
</div>
260334
</div>
261335

336+
<div>
337+
<Header2 spacing>Git settings</Header2>
338+
<div className="w-full rounded-sm border border-grid-dimmed p-4">
339+
<Fieldset>
340+
<InputGroup fullWidth>
341+
{githubAppInstallations.length === 0 && (
342+
<LinkButton
343+
to={githubAppInstallPath(organization.slug, location.pathname)}
344+
variant={"secondary/medium"}
345+
LeadingIcon={OctoKitty}
346+
>
347+
Install GitHub App
348+
</LinkButton>
349+
)}
350+
{githubAppInstallations.length !== 0 && (
351+
<div className="flex items-center gap-3">
352+
<ConnectGitHubRepoModal
353+
gitHubAppInstallations={githubAppInstallations}
354+
projectId={project.id}
355+
/>
356+
<span className="flex items-center gap-1 text-xs text-text-dimmed">
357+
<CheckCircleIcon className="size-4 text-success" /> GitHub app is
358+
installed
359+
</span>
360+
</div>
361+
)}
362+
363+
<Hint>
364+
Connect your GitHub repository to automatically deploy your changes.
365+
</Hint>
366+
</InputGroup>
367+
</Fieldset>
368+
</div>
369+
</div>
370+
262371
<div>
263372
<Header2 spacing>Danger zone</Header2>
264373
<div className="w-full rounded-sm border border-rose-500/40 p-4">
@@ -304,3 +413,167 @@ export default function Page() {
304413
</PageContainer>
305414
);
306415
}
416+
417+
const ConnectGitHubRepoFormSchema = z.object({
418+
installationId: z.string(),
419+
repositoryId: z.string(),
420+
projectId: z.string(),
421+
});
422+
423+
type GitHubRepository = {
424+
id: string;
425+
name: string;
426+
private: boolean;
427+
};
428+
429+
type GitHubAppInstallation = {
430+
id: string;
431+
targetType: string;
432+
repositories: GitHubRepository[];
433+
};
434+
435+
function ConnectGitHubRepoModal({
436+
gitHubAppInstallations,
437+
projectId: triggerProjectId,
438+
}: {
439+
gitHubAppInstallations: GitHubAppInstallation[];
440+
projectId: string;
441+
}) {
442+
const [isModalOpen, setIsModalOpen] = useState(false);
443+
const lastSubmission = useActionData();
444+
445+
const [selectedInstallation, setSelectedInstallation] = useState<
446+
GitHubAppInstallation | undefined
447+
>(gitHubAppInstallations.at(0));
448+
449+
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | undefined>(
450+
undefined
451+
);
452+
453+
const navigation = useNavigation();
454+
const isConnectRepositoryLoading =
455+
navigation.formData?.get("action") === "connect-repository" &&
456+
(navigation.state === "submitting" || navigation.state === "loading");
457+
458+
const [form, { installationId, repositoryId, projectId }] = useForm({
459+
id: "connect-repository",
460+
// TODO: type this
461+
lastSubmission: lastSubmission as any,
462+
shouldRevalidate: "onSubmit",
463+
onValidate({ formData }) {
464+
return parse(formData, {
465+
schema: ConnectGitHubRepoFormSchema,
466+
});
467+
},
468+
});
469+
470+
return (
471+
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
472+
<DialogTrigger asChild>
473+
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
474+
Connect GitHub repo
475+
</Button>
476+
</DialogTrigger>
477+
<DialogContent>
478+
<DialogHeader>Connect GitHub repository</DialogHeader>
479+
<div className="mt-2 flex flex-col gap-4">
480+
<Form method="post" {...form.props} className="w-full">
481+
<input {...conform.input(projectId, { type: "hidden" })} value={triggerProjectId} />
482+
<Paragraph className="mb-3">
483+
Choose a GitHub repository to connect to your project.
484+
</Paragraph>
485+
<Fieldset className="max-w-full gap-y-3">
486+
<InputGroup className="max-w-full">
487+
<Label htmlFor={installationId.id}>Account</Label>
488+
<Select
489+
name={installationId.name}
490+
id={installationId.id}
491+
value={selectedInstallation?.id}
492+
defaultValue={gitHubAppInstallations.at(0)?.id}
493+
setValue={(value) => {
494+
if (Array.isArray(value)) return;
495+
const installation = gitHubAppInstallations.find((i) => i.id === value);
496+
setSelectedInstallation(installation);
497+
setSelectedRepository(undefined);
498+
}}
499+
items={gitHubAppInstallations}
500+
variant="tertiary/small"
501+
placeholder="Select account"
502+
dropdownIcon
503+
text={
504+
selectedInstallation
505+
? `${selectedInstallation.targetType} ${selectedInstallation.id}`
506+
: undefined
507+
}
508+
>
509+
{gitHubAppInstallations.map((installation) => (
510+
<SelectItem key={installation.id} value={installation.id}>
511+
{installation.targetType} ({installation.id})
512+
</SelectItem>
513+
))}
514+
</Select>
515+
<FormError id={installationId.errorId}>{installationId.error}</FormError>
516+
</InputGroup>
517+
<InputGroup className="max-w-full">
518+
<Label htmlFor={repositoryId.id}>Repository</Label>
519+
<Select
520+
name={repositoryId.name}
521+
id={repositoryId.id}
522+
value={selectedRepository ? selectedRepository.id : undefined}
523+
setValue={(value) => {
524+
if (Array.isArray(value)) return;
525+
const repository = selectedInstallation?.repositories.find(
526+
(r) => r.id === value
527+
);
528+
setSelectedRepository(repository);
529+
}}
530+
variant="tertiary/small"
531+
placeholder="Select repository"
532+
heading="Filter repositories"
533+
dropdownIcon
534+
items={selectedInstallation?.repositories ?? []}
535+
filter={{ keys: ["name"] }}
536+
disabled={!selectedInstallation || selectedInstallation.repositories.length === 0}
537+
text={selectedRepository ? selectedRepository.name : null}
538+
>
539+
{(matches) =>
540+
matches.map((repo) => (
541+
<SelectItem key={repo.id} value={repo.id}>
542+
<div className="flex items-center gap-1">
543+
{repo.name}
544+
{repo.private && <LockClosedIcon className="size-3 text-text-dimmed" />}
545+
</div>
546+
</SelectItem>
547+
))
548+
}
549+
</Select>
550+
<FormError id={repositoryId.errorId}>{repositoryId.error}</FormError>
551+
</InputGroup>
552+
<FormError>{form.error}</FormError>
553+
<FormButtons
554+
confirmButton={
555+
<Button
556+
type="submit"
557+
name="action"
558+
value="connect-repository"
559+
variant="primary/medium"
560+
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
561+
leadingIconClassName="text-white"
562+
disabled={isConnectRepositoryLoading}
563+
>
564+
Connect repository
565+
</Button>
566+
}
567+
cancelButton={
568+
<DialogClose asChild>
569+
<Button variant="tertiary/medium">Cancel</Button>
570+
</DialogClose>
571+
}
572+
/>
573+
</Fieldset>
574+
</Form>
575+
</div>
576+
</DialogContent>
577+
</Dialog>
578+
);
579+
}

apps/webapp/app/utils/pathBuilder.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ export function v3ProjectPath(organization: OrgForPath, project: ProjectForPath)
141141
return `/orgs/${organizationParam(organization)}/projects/${projectParam(project)}`;
142142
}
143143

144+
export function githubAppInstallPath(organizationSlug: string, redirectTo: string) {
145+
return `/github/install?org_slug=${organizationSlug}&redirect_to=${redirectTo}`;
146+
}
147+
144148
export function v3EnvironmentPath(
145149
organization: OrgForPath,
146150
project: ProjectForPath,

0 commit comments

Comments
 (0)