ProjectLinker.tsx412 lines · main
1// @ts-nocheck
2import { Check, ChevronDown, Plus, PlusIcon } from 'lucide-react'
3import Link from 'next/link'
4import { useRouter } from 'next/router'
5import { ReactNode, useEffect, useState } from 'react'
6import { toast } from 'sonner'
7import {
8 Badge,
9 Button,
10 cn,
11 Command,
12 CommandEmpty,
13 CommandGroup,
14 CommandInput,
15 CommandItem,
16 CommandList,
17 CommandSeparator,
18 Popover,
19 PopoverContent,
20 PopoverTrigger,
21} from 'ui'
22
23import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector'
24import ShimmerLine from '@/components/ui/ShimmerLine'
25import {
26 IntegrationConnectionsCreateVariables,
27 IntegrationProjectConnection,
28} from '@/data/integrations/integrations.types'
29import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
30import { useProjectDetailQuery } from '@/data/projects/project-detail-query'
31import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
32import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
33import { BASE_PATH } from '@/lib/constants'
34import { openInstallGitHubIntegrationWindow } from '@/lib/github'
35import { EMPTY_ARR } from '@/lib/void'
36
37export interface Project {
38 name: string
39 ref: string
40}
41
42export interface ForeignProject {
43 id: string
44 name: string
45 installation_id?: number
46}
47
48export interface ProjectLinkerProps {
49 slug?: string
50 organizationIntegrationId?: string
51 foreignProjects: ForeignProject[]
52 onCreateConnections: (variables: IntegrationConnectionsCreateVariables) => void
53 installedConnections?: IntegrationProjectConnection[]
54 isLoading?: boolean
55 integrationIcon: ReactNode
56 getForeignProjectIcon?: (project: ForeignProject) => ReactNode
57 choosePrompt?: string
58 onSkip?: () => void
59 loadingForeignProjects?: boolean
60 showNoEntitiesState?: boolean
61
62 defaultBrivenProjectRef?: string
63 defaultForeignProjectId?: string
64 mode: 'Vercel' | 'GitHub'
65}
66
67const ProjectLinker = ({
68 slug,
69 organizationIntegrationId,
70 foreignProjects,
71 onCreateConnections: _onCreateConnections,
72 installedConnections = EMPTY_ARR,
73 isLoading,
74 integrationIcon,
75 getForeignProjectIcon,
76 choosePrompt = 'Choose a project',
77 onSkip,
78 loadingForeignProjects,
79 showNoEntitiesState = true,
80
81 defaultBrivenProjectRef,
82 defaultForeignProjectId,
83 mode,
84}: ProjectLinkerProps) => {
85 const router = useRouter()
86 const projectCreationEnabled = useIsFeatureEnabled('projects:create')
87
88 const [openProjectsDropdown, setOpenProjectsDropdown] = useState(false)
89 const [openForeignProjectsComboBox, setOpenForeignProjectsComboBox] = useState(false)
90 const [foreignProjectId, setForeignProjectId] = useState<string | undefined>(
91 defaultForeignProjectId
92 )
93 const [brivenProjectRef, setBrivenProjectRef] = useState<string | undefined>(
94 defaultBrivenProjectRef
95 )
96
97 const { data: selectedOrganization } = useSelectedOrganizationQuery()
98 const { data: orgProjects, isPending: loadingBrivenProjects } = useOrgProjectsInfiniteQuery({
99 slug,
100 })
101 const numProjects = orgProjects?.pages[0].pagination.count ?? 0
102
103 useEffect(() => {
104 if (defaultBrivenProjectRef !== undefined && brivenProjectRef === undefined)
105 setBrivenProjectRef(defaultBrivenProjectRef)
106 }, [defaultBrivenProjectRef, brivenProjectRef])
107
108 useEffect(() => {
109 if (defaultForeignProjectId !== undefined && foreignProjectId === undefined)
110 setForeignProjectId(defaultForeignProjectId)
111 }, [defaultForeignProjectId, foreignProjectId])
112
113 // create a flat array of foreign project ids. ie, ["prj_MlkO6AiLG5ofS9ojKrkS3PhhlY3f", ..]
114 const flatInstalledConnectionsIds = new Set(installedConnections.map((x) => x.foreign_project_id))
115
116 const { data: selectedBrivenProject } = useProjectDetailQuery({ ref: brivenProjectRef })
117
118 const selectedForeignProject = foreignProjectId
119 ? foreignProjects.find((x) => x.id?.toLowerCase() === foreignProjectId?.toLowerCase())
120 : undefined
121
122 function onCreateConnections() {
123 const projectDetails = selectedForeignProject
124
125 if (!selectedForeignProject?.id) return console.error('No Foreign project ID set')
126 if (!selectedBrivenProject?.ref) return console.error('No Briven project ref set')
127
128 const alreadyInstalled = flatInstalledConnectionsIds.has(foreignProjectId ?? '')
129 if (alreadyInstalled) {
130 return toast.error(
131 `Unable to connect to ${selectedForeignProject.name}: Selected repository already has an installed connection to a project`
132 )
133 }
134
135 _onCreateConnections({
136 organizationIntegrationId: organizationIntegrationId!,
137 connection: {
138 foreign_project_id: selectedForeignProject?.id,
139 briven_project_ref: selectedBrivenProject?.ref,
140 integration_id: '0',
141 metadata: {
142 ...projectDetails,
143 },
144 },
145 orgSlug: selectedOrganization?.slug,
146 new: {
147 installation_id: selectedForeignProject.installation_id!,
148 project_ref: selectedBrivenProject.ref,
149 repository_id: Number(selectedForeignProject.id),
150 },
151 })
152 }
153
154 const Panel = ({ children, className, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
155 return (
156 <div
157 className={cn(
158 'flex-1 min-w-0 flex flex-col grow gap-6 px-5 mx-auto w-full justify-center items-center',
159 className
160 )}
161 {...props}
162 >
163 {children}
164 </div>
165 )
166 }
167
168 const noBrivenProjects = numProjects === 0
169 const noForeignProjects = foreignProjects.length === 0
170 const missingEntity = noBrivenProjects ? 'Briven' : mode
171 const oppositeMissingEntity = noBrivenProjects ? mode : 'Briven'
172
173 return (
174 <div className="flex flex-col bg border shadow-sm rounded-lg overflow-hidden">
175 <div className="relative p-12 border-b border-muted">
176 <div
177 className="absolute inset-0 bg-grid-black/5 mask-[linear-gradient(0deg,#fff,rgba(255,255,255,0.6))] dark:bg-grid-white/5 dark:mask-[linear-gradient(0deg,rgba(255,255,255,0.1),rgba(255,255,255,0.5))]"
178 style={{ backgroundPosition: '10px 10px' }}
179 />
180
181 {loadingForeignProjects ? (
182 <div className="w-1/2 mx-auto space-y-2 py-4">
183 <p className="text-sm text-foreground text-center">Loading projects</p>
184 <ShimmerLine active />
185 </div>
186 ) : showNoEntitiesState && (noBrivenProjects || noForeignProjects) ? (
187 <div className="text-center">
188 <h5 className="text-foreground">No {missingEntity} Projects found</h5>
189 <p className="text-foreground-light text-sm">
190 You will need to create a {missingEntity} Project to link to a {oppositeMissingEntity}{' '}
191 Project.
192 <br />
193 You can skip this and create a Project Connection later.
194 </p>
195 </div>
196 ) : (
197 <div className="flex justify-center gap-0 w-full relative">
198 <Panel>
199 <div className="bg-white shadow-sm border rounded-sm p-1 w-12 h-12 flex justify-center items-center">
200 <img src={`${BASE_PATH}/img/briven-logo.svg`} alt="Briven" className="w-6" />
201 </div>
202
203 <OrganizationProjectSelector
204 sameWidthAsTrigger
205 open={openProjectsDropdown}
206 setOpen={setOpenProjectsDropdown}
207 slug={slug}
208 selectedRef={brivenProjectRef}
209 onSelect={(project) => {
210 setBrivenProjectRef(project.ref)
211 setOpenProjectsDropdown(false)
212 }}
213 renderRow={(project) => {
214 return (
215 <div className={cn('w-full flex items-center justify-between')}>
216 <div className="flex items-center gap-x-2">
217 <div className="bg-white shadow-sm border rounded-sm p-1 w-6 h-6 flex justify-center items-center">
218 <img
219 src={`${BASE_PATH}/img/briven-logo.svg`}
220 alt="Briven"
221 className="w-4"
222 />
223 </div>
224 <p>{project.name}</p>
225 {project.status === 'INACTIVE' && <Badge>Paused</Badge>}
226 {project.status === 'GOING_DOWN' && <Badge>Pausing</Badge>}
227 </div>
228 {project.ref === brivenProjectRef && <Check size={16} />}
229 </div>
230 )
231 }}
232 renderTrigger={() => {
233 return (
234 <Button
235 type="default"
236 block
237 disabled={defaultBrivenProjectRef !== undefined || loadingBrivenProjects}
238 loading={loadingBrivenProjects}
239 className="justify-between h-[34px]"
240 iconRight={
241 defaultBrivenProjectRef === undefined ? (
242 <span className="grow flex justify-end">
243 <ChevronDown />
244 </span>
245 ) : null
246 }
247 >
248 <div className="flex items-center gap-x-2">
249 <div className="bg-white shadow-sm border rounded-sm p-1 w-6 h-6 flex justify-center items-center">
250 <img
251 src={`${BASE_PATH}/img/briven-logo.svg`}
252 alt="Briven"
253 className="w-4"
254 />
255 </div>
256 {selectedBrivenProject
257 ? selectedBrivenProject.name
258 : 'Choose Briven Project'}
259 </div>
260 </Button>
261 )
262 }}
263 renderActions={() => {
264 return (
265 projectCreationEnabled && (
266 <CommandGroup>
267 <CommandItem
268 className="cursor-pointer w-full"
269 onSelect={() => {
270 setOpenProjectsDropdown(false)
271 router.push(`/new/${selectedOrganization?.slug}`)
272 }}
273 onClick={() => setOpenProjectsDropdown(false)}
274 >
275 <Link
276 href={`/new/${selectedOrganization?.slug}`}
277 onClick={() => {
278 setOpenProjectsDropdown(false)
279 }}
280 className="w-full flex items-center gap-2"
281 >
282 <Plus size={14} strokeWidth={1.5} />
283 <p>Create a new project</p>
284 </Link>
285 </CommandItem>
286 </CommandGroup>
287 )
288 )
289 }}
290 />
291 </Panel>
292
293 <div className="border border-foreground-lighter h-px w-8 border-dashed self-end mb-4" />
294
295 <Panel>
296 <div className="bg-black shadow-sm rounded-sm p-1 w-12 h-12 flex justify-center items-center">
297 {integrationIcon}
298 </div>
299
300 <Popover
301 open={openForeignProjectsComboBox}
302 onOpenChange={setOpenForeignProjectsComboBox}
303 >
304 <PopoverTrigger asChild>
305 <Button
306 type="default"
307 block
308 disabled={loadingForeignProjects}
309 loading={loadingForeignProjects}
310 className="justify-start h-[34px]"
311 icon={
312 <div>
313 {selectedForeignProject
314 ? (getForeignProjectIcon?.(selectedForeignProject) ?? integrationIcon)
315 : integrationIcon}
316 </div>
317 }
318 iconRight={
319 <span className="grow flex justify-end">
320 <ChevronDown />
321 </span>
322 }
323 >
324 {(selectedForeignProject && selectedForeignProject.name) ?? choosePrompt}
325 </Button>
326 </PopoverTrigger>
327 <PopoverContent className="p-0" side="bottom" align="center" sameWidthAsTrigger>
328 <Command>
329 <CommandInput placeholder="Search for a project" />
330 <CommandList className="max-h-[170px]!">
331 <CommandEmpty>No results found.</CommandEmpty>
332 <CommandGroup>
333 {foreignProjects.map((project, i) => {
334 return (
335 <CommandItem
336 key={project.id}
337 value={`${project.name.replaceAll('"', '')}-${i}`}
338 className="flex gap-2 items-center"
339 onSelect={() => {
340 if (project.id) setForeignProjectId(project.id)
341 setOpenForeignProjectsComboBox(false)
342 }}
343 >
344 <div>{getForeignProjectIcon?.(project) ?? integrationIcon}</div>
345 <span className="truncate" title={project.name}>
346 {project.name}
347 </span>
348 </CommandItem>
349 )
350 })}
351 {foreignProjects.length === 0 && (
352 <CommandEmpty>No results found.</CommandEmpty>
353 )}
354 </CommandGroup>
355 {mode === 'GitHub' && (
356 <>
357 <CommandSeparator />
358 <CommandGroup>
359 <CommandItem
360 className="flex gap-2 items-center cursor-pointer"
361 onSelect={() => openInstallGitHubIntegrationWindow('install')}
362 >
363 <PlusIcon size={16} />
364 Add GitHub Repositories
365 </CommandItem>
366 </CommandGroup>
367 </>
368 )}
369 </CommandList>
370 </Command>
371 </PopoverContent>
372 </Popover>
373 </Panel>
374 </div>
375 )}
376 </div>
377
378 <div className="flex w-full justify-end gap-2 p-4 bg-surface-75">
379 {onSkip !== undefined && (
380 <Button
381 size="medium"
382 type="default"
383 onClick={() => {
384 onSkip()
385 }}
386 >
387 Skip
388 </Button>
389 )}
390 <Button
391 size="medium"
392 className="self-end"
393 onClick={onCreateConnections}
394 loading={isLoading}
395 disabled={
396 // data loading states
397 loadingForeignProjects ||
398 loadingBrivenProjects ||
399 isLoading ||
400 // check whether both project types are not undefined
401 !selectedBrivenProject ||
402 !selectedForeignProject
403 }
404 >
405 Connect project
406 </Button>
407 </div>
408 </div>
409 )
410}
411
412export default ProjectLinker