GitHubRepositoryField.tsx255 lines · main
| 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 | |
| 21 | import { useGitHubAuthorizationQuery } from '@/data/integrations/github-authorization-query' |
| 22 | import { useGitHubRepositoriesQuery } from '@/data/integrations/github-repositories-query' |
| 23 | import { openInstallGitHubIntegrationWindow } from '@/lib/github' |
| 24 | import { EMPTY_ARR } from '@/lib/void' |
| 25 | |
| 26 | export type GitHubRepository = { |
| 27 | id: string |
| 28 | name: string |
| 29 | installation_id: number |
| 30 | default_branch: string |
| 31 | } |
| 32 | |
| 33 | export 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 | |
| 45 | export 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 | |
| 87 | interface 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 | |
| 107 | export 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 | } |