GitHubRepositoryField.tsx255 lines · main
1import { ChevronDown, PlusIcon, RefreshCw } from 'lucide-react'
2import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'
3import type { FieldValues, Path, UseFormReturn } from 'react-hook-form'
4import {
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'
19import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
20
21import { useGitHubAuthorizationQuery } from '@/data/integrations/github-authorization-query'
22import { useGitHubRepositoriesQuery } from '@/data/integrations/github-repositories-query'
23import { openInstallGitHubIntegrationWindow } from '@/lib/github'
24import { EMPTY_ARR } from '@/lib/void'
25
26export type GitHubRepository = {
27 id: string
28 name: string
29 installation_id: number
30 default_branch: string
31}
32
33export const GITHUB_ICON = (
34 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98 96" className="w-5 shrink-0">
35 <title>GitHub icon</title>
36 <path
37 fill="currentColor"
38 fillRule="evenodd"
39 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"
40 clipRule="evenodd"
41 />
42 </svg>
43)
44
45export const useGitHubRepositoryOptions = () => {
46 const {
47 data: gitHubAuthorization,
48 isPending: isLoadingGitHubAuthorization,
49 refetch: refetchGitHubAuthorization,
50 } = useGitHubAuthorizationQuery()
51
52 const {
53 data: githubReposData,
54 isPending: isLoadingGitHubRepos,
55 refetch: refetchGitHubRepositories,
56 } = useGitHubRepositoriesQuery({
57 enabled: Boolean(gitHubAuthorization),
58 })
59
60 const githubRepos = useMemo<GitHubRepository[]>(
61 () =>
62 githubReposData?.repositories?.map((repo) => ({
63 id: repo.id.toString(),
64 name: repo.name,
65 installation_id: repo.installation_id,
66 default_branch: repo.default_branch || 'main',
67 })) ?? EMPTY_ARR,
68 [githubReposData]
69 )
70
71 const refetchGitHubAuthorizationAndRepositories = () => {
72 setTimeout(() => {
73 refetchGitHubAuthorization()
74 refetchGitHubRepositories()
75 }, 2000)
76 }
77
78 return {
79 gitHubAuthorization,
80 githubRepos,
81 hasPartialResponseDueToSSO: githubReposData?.partial_response_due_to_sso ?? false,
82 isLoading: isLoadingGitHubAuthorization || isLoadingGitHubRepos,
83 refetch: refetchGitHubAuthorizationAndRepositories,
84 }
85}
86
87interface GitHubRepositoryFieldProps<TFormValues extends FieldValues> {
88 form: UseFormReturn<TFormValues>
89 name: Path<TFormValues>
90 label: string
91 description?: ReactNode
92 layout?: ComponentProps<typeof FormItemLayout>['layout']
93 disabled?: boolean
94 selectedRepositoryName?: string
95 installationIdField?: Path<TFormValues>
96 repositoryNameField?: Path<TFormValues>
97 repositories: GitHubRepository[]
98 gitHubAuthorization: unknown | null
99 hasPartialResponseDueToSSO?: boolean
100 isLoading?: boolean
101 placeholder?: string
102 refetch: () => void
103 onConnectClick?: () => void
104 onRepositorySelect?: (repo: GitHubRepository) => void
105}
106
107export const GitHubRepositoryField = <TFormValues extends FieldValues>({
108 form,
109 name,
110 label,
111 description,
112 layout = 'horizontal',
113 disabled = false,
114 selectedRepositoryName,
115 installationIdField,
116 repositoryNameField,
117 repositories,
118 gitHubAuthorization,
119 hasPartialResponseDueToSSO = false,
120 isLoading = false,
121 placeholder = 'Choose GitHub repository',
122 refetch,
123 onConnectClick,
124 onRepositorySelect,
125}: GitHubRepositoryFieldProps<TFormValues>) => {
126 const [isRepoSelectorOpen, setIsRepoSelectorOpen] = useState(false)
127
128 const currentRepositoryId = form.watch(name) as string | undefined
129 const selectedRepository = repositories.find((repo) => repo.id === currentRepositoryId)
130
131 return (
132 <FormField
133 control={form.control}
134 name={name}
135 render={({ field }) => (
136 <FormItemLayout label={label} layout={layout} description={description}>
137 {gitHubAuthorization === null ? (
138 <FormControl>
139 <Button
140 type="default"
141 size="small"
142 htmlType="button"
143 disabled={disabled}
144 onClick={() => {
145 onConnectClick?.()
146 openInstallGitHubIntegrationWindow('authorize', refetch)
147 }}
148 icon={GITHUB_ICON}
149 >
150 Connect GitHub
151 </Button>
152 </FormControl>
153 ) : (
154 <Popover open={isRepoSelectorOpen} onOpenChange={setIsRepoSelectorOpen}>
155 <PopoverTrigger asChild>
156 <FormControl>
157 <Button
158 type="default"
159 htmlType="button"
160 className="justify-start h-[34px] w-full"
161 disabled={disabled || isLoading}
162 loading={isLoading}
163 icon={GITHUB_ICON}
164 iconRight={
165 <span className="grow flex justify-end">
166 <ChevronDown />
167 </span>
168 }
169 >
170 {selectedRepository?.name ||
171 selectedRepositoryName ||
172 (isLoading ? 'Loading GitHub repositories...' : placeholder)}
173 </Button>
174 </FormControl>
175 </PopoverTrigger>
176 <PopoverContent className="p-0" side="bottom" align="start" sameWidthAsTrigger>
177 <Command>
178 <CommandInput placeholder="Search repositories..." />
179 <CommandList className="!max-h-[220px]">
180 <CommandEmpty>No repositories found.</CommandEmpty>
181 {repositories.length > 0 ? (
182 <CommandGroup>
183 {repositories.map((repo) => (
184 <CommandItem
185 key={repo.id}
186 value={`${repo.name.replaceAll('"', '')}-${repo.id}`}
187 className="flex gap-2 items-center"
188 onSelect={() => {
189 field.onChange(repo.id)
190
191 if (installationIdField !== undefined) {
192 form.setValue(installationIdField, repo.installation_id as never, {
193 shouldDirty: true,
194 })
195 }
196
197 if (repositoryNameField !== undefined) {
198 form.setValue(repositoryNameField, repo.name as never, {
199 shouldDirty: true,
200 })
201 }
202
203 onRepositorySelect?.(repo)
204 setIsRepoSelectorOpen(false)
205 }}
206 >
207 {GITHUB_ICON}
208 <span className="truncate" title={repo.name}>
209 {repo.name}
210 </span>
211 </CommandItem>
212 ))}
213 </CommandGroup>
214 ) : null}
215 <CommandGroup>
216 <CommandItem
217 className="flex gap-2 items-center cursor-pointer"
218 onSelect={() => {
219 setIsRepoSelectorOpen(false)
220 openInstallGitHubIntegrationWindow('install', refetch)
221 }}
222 >
223 <PlusIcon size={16} />
224 Add GitHub Repositories
225 </CommandItem>
226 </CommandGroup>
227 {hasPartialResponseDueToSSO && (
228 <>
229 <CommandSeparator />
230 <CommandGroup>
231 <CommandItem
232 className="flex gap-2 items-start cursor-pointer"
233 onSelect={() => {
234 setIsRepoSelectorOpen(false)
235 openInstallGitHubIntegrationWindow('authorize', refetch)
236 }}
237 >
238 <RefreshCw size={16} className="mt-0.5 shrink-0" />
239 <div className="text-xs text-foreground-light">
240 Re-authorize GitHub with SSO to show all repositories
241 </div>
242 </CommandItem>
243 </CommandGroup>
244 </>
245 )}
246 </CommandList>
247 </Command>
248 </PopoverContent>
249 </Popover>
250 )}
251 </FormItemLayout>
252 )}
253 />
254 )
255}