ProjectLinker.tsx 15 KB

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