GitHubRepositoryField.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. import { ChevronDown, PlusIcon, RefreshCw } from 'lucide-react'
  2. import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'
  3. import type { FieldValues, Path, UseFormReturn } from 'react-hook-form'
  4. import {
  5. Button,
  6. Command,
  7. CommandEmpty,
  8. CommandGroup,
  9. CommandInput,
  10. CommandItem,
  11. CommandList,
  12. CommandSeparator,
  13. FormControl,
  14. FormField,
  15. Popover,
  16. PopoverContent,
  17. PopoverTrigger,
  18. } from 'ui'
  19. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  20. import { useGitHubAuthorizationQuery } from '@/data/integrations/github-authorization-query'
  21. import { useGitHubRepositoriesQuery } from '@/data/integrations/github-repositories-query'
  22. import { openInstallGitHubIntegrationWindow } from '@/lib/github'
  23. import { EMPTY_ARR } from '@/lib/void'
  24. export type GitHubRepository = {
  25. id: string
  26. name: string
  27. installation_id: number
  28. default_branch: string
  29. }
  30. export const GITHUB_ICON = (
  31. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98 96" className="w-5 shrink-0">
  32. <title>GitHub icon</title>
  33. <path
  34. fill="currentColor"
  35. fillRule="evenodd"
  36. d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
  37. clipRule="evenodd"
  38. />
  39. </svg>
  40. )
  41. export const useGitHubRepositoryOptions = () => {
  42. const {
  43. data: gitHubAuthorization,
  44. isPending: isLoadingGitHubAuthorization,
  45. refetch: refetchGitHubAuthorization,
  46. } = useGitHubAuthorizationQuery()
  47. const {
  48. data: githubReposData,
  49. isPending: isLoadingGitHubRepos,
  50. refetch: refetchGitHubRepositories,
  51. } = useGitHubRepositoriesQuery({
  52. enabled: Boolean(gitHubAuthorization),
  53. })
  54. const githubRepos = useMemo<GitHubRepository[]>(
  55. () =>
  56. githubReposData?.repositories?.map((repo) => ({
  57. id: repo.id.toString(),
  58. name: repo.name,
  59. installation_id: repo.installation_id,
  60. default_branch: repo.default_branch || 'main',
  61. })) ?? EMPTY_ARR,
  62. [githubReposData]
  63. )
  64. const refetchGitHubAuthorizationAndRepositories = () => {
  65. setTimeout(() => {
  66. refetchGitHubAuthorization()
  67. refetchGitHubRepositories()
  68. }, 2000)
  69. }
  70. return {
  71. gitHubAuthorization,
  72. githubRepos,
  73. hasPartialResponseDueToSSO: githubReposData?.partial_response_due_to_sso ?? false,
  74. isLoading: isLoadingGitHubAuthorization || isLoadingGitHubRepos,
  75. refetch: refetchGitHubAuthorizationAndRepositories,
  76. }
  77. }
  78. interface GitHubRepositoryFieldProps<TFormValues extends FieldValues> {
  79. form: UseFormReturn<TFormValues>
  80. name: Path<TFormValues>
  81. label: string
  82. description?: ReactNode
  83. layout?: ComponentProps<typeof FormItemLayout>['layout']
  84. disabled?: boolean
  85. selectedRepositoryName?: string
  86. installationIdField?: Path<TFormValues>
  87. repositoryNameField?: Path<TFormValues>
  88. repositories: GitHubRepository[]
  89. gitHubAuthorization: unknown | null
  90. hasPartialResponseDueToSSO?: boolean
  91. isLoading?: boolean
  92. placeholder?: string
  93. refetch: () => void
  94. onConnectClick?: () => void
  95. onRepositorySelect?: (repo: GitHubRepository) => void
  96. }
  97. export const GitHubRepositoryField = <TFormValues extends FieldValues>({
  98. form,
  99. name,
  100. label,
  101. description,
  102. layout = 'horizontal',
  103. disabled = false,
  104. selectedRepositoryName,
  105. installationIdField,
  106. repositoryNameField,
  107. repositories,
  108. gitHubAuthorization,
  109. hasPartialResponseDueToSSO = false,
  110. isLoading = false,
  111. placeholder = 'Choose GitHub repository',
  112. refetch,
  113. onConnectClick,
  114. onRepositorySelect,
  115. }: GitHubRepositoryFieldProps<TFormValues>) => {
  116. const [isRepoSelectorOpen, setIsRepoSelectorOpen] = useState(false)
  117. const currentRepositoryId = form.watch(name) as string | undefined
  118. const selectedRepository = repositories.find((repo) => repo.id === currentRepositoryId)
  119. return (
  120. <FormField
  121. control={form.control}
  122. name={name}
  123. render={({ field }) => (
  124. <FormItemLayout label={label} layout={layout} description={description}>
  125. {gitHubAuthorization === null ? (
  126. <FormControl>
  127. <Button
  128. type="default"
  129. size="small"
  130. htmlType="button"
  131. disabled={disabled}
  132. onClick={() => {
  133. onConnectClick?.()
  134. openInstallGitHubIntegrationWindow('authorize', refetch)
  135. }}
  136. icon={GITHUB_ICON}
  137. >
  138. Connect GitHub
  139. </Button>
  140. </FormControl>
  141. ) : (
  142. <Popover open={isRepoSelectorOpen} onOpenChange={setIsRepoSelectorOpen}>
  143. <PopoverTrigger asChild>
  144. <FormControl>
  145. <Button
  146. type="default"
  147. htmlType="button"
  148. className="justify-start h-[34px] w-full"
  149. disabled={disabled || isLoading}
  150. loading={isLoading}
  151. icon={GITHUB_ICON}
  152. iconRight={
  153. <span className="grow flex justify-end">
  154. <ChevronDown />
  155. </span>
  156. }
  157. >
  158. {selectedRepository?.name ||
  159. selectedRepositoryName ||
  160. (isLoading ? 'Loading GitHub repositories...' : placeholder)}
  161. </Button>
  162. </FormControl>
  163. </PopoverTrigger>
  164. <PopoverContent className="p-0" side="bottom" align="start" sameWidthAsTrigger>
  165. <Command>
  166. <CommandInput placeholder="Search repositories..." />
  167. <CommandList className="!max-h-[220px]">
  168. <CommandEmpty>No repositories found.</CommandEmpty>
  169. {repositories.length > 0 ? (
  170. <CommandGroup>
  171. {repositories.map((repo) => (
  172. <CommandItem
  173. key={repo.id}
  174. value={`${repo.name.replaceAll('"', '')}-${repo.id}`}
  175. className="flex gap-2 items-center"
  176. onSelect={() => {
  177. field.onChange(repo.id)
  178. if (installationIdField !== undefined) {
  179. form.setValue(installationIdField, repo.installation_id as never, {
  180. shouldDirty: true,
  181. })
  182. }
  183. if (repositoryNameField !== undefined) {
  184. form.setValue(repositoryNameField, repo.name as never, {
  185. shouldDirty: true,
  186. })
  187. }
  188. onRepositorySelect?.(repo)
  189. setIsRepoSelectorOpen(false)
  190. }}
  191. >
  192. {GITHUB_ICON}
  193. <span className="truncate" title={repo.name}>
  194. {repo.name}
  195. </span>
  196. </CommandItem>
  197. ))}
  198. </CommandGroup>
  199. ) : null}
  200. <CommandGroup>
  201. <CommandItem
  202. className="flex gap-2 items-center cursor-pointer"
  203. onSelect={() => {
  204. setIsRepoSelectorOpen(false)
  205. openInstallGitHubIntegrationWindow('install', refetch)
  206. }}
  207. >
  208. <PlusIcon size={16} />
  209. Add GitHub Repositories
  210. </CommandItem>
  211. </CommandGroup>
  212. {hasPartialResponseDueToSSO && (
  213. <>
  214. <CommandSeparator />
  215. <CommandGroup>
  216. <CommandItem
  217. className="flex gap-2 items-start cursor-pointer"
  218. onSelect={() => {
  219. setIsRepoSelectorOpen(false)
  220. openInstallGitHubIntegrationWindow('authorize', refetch)
  221. }}
  222. >
  223. <RefreshCw size={16} className="mt-0.5 shrink-0" />
  224. <div className="text-xs text-foreground-light">
  225. Re-authorize GitHub with SSO to show all repositories
  226. </div>
  227. </CommandItem>
  228. </CommandGroup>
  229. </>
  230. )}
  231. </CommandList>
  232. </Command>
  233. </PopoverContent>
  234. </Popover>
  235. )}
  236. </FormItemLayout>
  237. )}
  238. />
  239. )
  240. }