11import { conform , useForm } from "@conform-to/react" ;
22import { 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" ;
518import { type ActionFunction , json } from "@remix-run/server-runtime" ;
19+ import { typedjson , useTypedLoaderData } from "remix-typedjson" ;
620import { z } from "zod" ;
721import { AdminDebugTooltip } from "~/components/admin/debugTooltip" ;
822import { 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" ;
926import {
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" ;
1532import { ClipboardField } from "~/components/primitives/ClipboardField" ;
1633import { Fieldset } from "~/components/primitives/Fieldset" ;
1734import { FormButtons } from "~/components/primitives/FormButtons" ;
@@ -26,12 +43,21 @@ import { Paragraph } from "~/components/primitives/Paragraph";
2643import * as Property from "~/components/primitives/PropertyTable" ;
2744import { SpinnerWhite } from "~/components/primitives/Spinner" ;
2845import { prisma } from "~/db.server" ;
46+ import { useOrganization } from "~/hooks/useOrganizations" ;
2947import { useProject } from "~/hooks/useProject" ;
3048import { redirectWithErrorMessage , redirectWithSuccessMessage } from "~/models/message.server" ;
49+ import { findProjectBySlug } from "~/models/project.server" ;
3150import { DeleteProjectService } from "~/services/deleteProject.server" ;
3251import { logger } from "~/services/logger.server" ;
3352import { 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
3662export 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+
44115export 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
150221export 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+ }
0 commit comments