Files
supabase/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField.tsx
T
Joshen Lim 2b393377c6 Show connection settings if already configured despite no GH auth (#51427)
## Context

A project could already have a GitHub integration set up by another
member, which will show up in the branch management side navigation as
such:
<img width="259" height="255" alt="image"
src="https://github.com/user-attachments/assets/8bc12ef2-5f25-4245-9c89-4563b08a42a8"
/>

However if the user hasn't authenticated with GitHub, the project
settings -> integrations page will obfuscate the integration details
with a "Connect GitHub" CTA, which is a confusing UX as it hints that
there's no integration set up + conflicts with what's on the branch
management page
<img width="768" height="318" alt="image"
src="https://github.com/user-attachments/assets/a68409c0-f2db-407d-9bac-c109a0d14214"
/>

## Changes involved

Opting to show the integration details if there's one set up
irregardless of whether the user has authenticated with GH, but we'll
require the user to complete the authentication to manage the
integration settings as such (all input fields will also be disabled
until the auth is complete):
<img width="1031" height="511" alt="image"
src="https://github.com/user-attachments/assets/6fad387b-6e76-475e-b4e6-76972d8f76b6"
/>

Note that you can still disable the integration nonetheless irregardless
of the GH connection status

Also fixes the "Remove connection" CTA from Account Preferences - we
were invalidating the GH authorization query cache but the GH
authorization requests normally fails with a 404 if there's no auth set
up, so the query cache would remain stale. Opting to clear the query
cache instead of invalidating

## To test
- [ ] Set up a GH integration for your project under project settings ->
integrations
- [ ] Head to account preferences to remove your GH connection
- [ ] Go back to the project settings -> integrations page, should still
see the GH integration details, just unable to edit
- [ ] Connect with GH, and you should be able to edit the integration
details thereafter
2026-10-09 16:28:49 +08:00

226 lines
8.0 KiB
TypeScript

import { ChevronDown, PlusIcon, RefreshCw } from 'lucide-react'
import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'
import { useWatch, type FieldValues, type Path, type UseFormReturn } from 'react-hook-form'
import {
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
FormControl,
FormField,
Popover,
PopoverContent,
PopoverTrigger,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { ConnectGitHubButton } from './ConnectGitHubButton'
import { useGitHubAuthorizationQuery } from '@/data/integrations/github-authorization-query'
import { useGitHubRepositoriesQuery } from '@/data/integrations/github-repositories-query'
import { openInstallGitHubIntegrationWindow } from '@/lib/github'
import { EMPTY_ARR } from '@/lib/void'
export type GitHubRepository = {
id: string
name: string
installation_id: number
default_branch: string
}
export const useGitHubRepositoryOptions = () => {
const {
data: gitHubAuthorization,
isPending: isLoadingGitHubAuthorization,
refetch: refetchGitHubAuthorization,
} = useGitHubAuthorizationQuery()
const {
data: githubReposData,
isPending: isLoadingGitHubRepos,
refetch: refetchGitHubRepositories,
} = useGitHubRepositoriesQuery({
enabled: Boolean(gitHubAuthorization),
})
const githubRepos = useMemo<GitHubRepository[]>(
() =>
githubReposData?.repositories?.map((repo) => ({
id: repo.id.toString(),
name: repo.name,
installation_id: repo.installation_id,
default_branch: repo.default_branch || 'main',
})) ?? EMPTY_ARR,
[githubReposData]
)
const refetchGitHubAuthorizationAndRepositories = () => {
setTimeout(() => {
refetchGitHubAuthorization()
refetchGitHubRepositories()
}, 2000)
}
return {
gitHubAuthorization,
githubRepos,
hasPartialResponseDueToSSO: githubReposData?.partial_response_due_to_sso ?? false,
isLoading: isLoadingGitHubAuthorization || isLoadingGitHubRepos,
refetch: refetchGitHubAuthorizationAndRepositories,
}
}
interface GitHubRepositoryFieldProps<TFormValues extends FieldValues> {
form: UseFormReturn<TFormValues>
name: Path<TFormValues>
label: string
description?: ReactNode
layout?: ComponentProps<typeof FormItemLayout>['layout']
disabled?: boolean
selectedRepositoryName?: string
installationIdField?: Path<TFormValues>
repositoryNameField?: Path<TFormValues>
repositories: GitHubRepository[]
gitHubAuthorization: unknown | null
hasPartialResponseDueToSSO?: boolean
isLoading?: boolean
placeholder?: string
refetch: () => void
onConnectClick?: () => void
onRepositorySelect?: (repo: GitHubRepository) => void
}
export const GitHubRepositoryField = <TFormValues extends FieldValues>({
form,
name,
label,
description,
layout = 'horizontal',
disabled = false,
selectedRepositoryName,
installationIdField,
repositoryNameField,
repositories,
gitHubAuthorization,
hasPartialResponseDueToSSO = false,
isLoading = false,
placeholder = 'Choose GitHub repository',
refetch,
onConnectClick,
onRepositorySelect,
}: GitHubRepositoryFieldProps<TFormValues>) => {
const [isRepoSelectorOpen, setIsRepoSelectorOpen] = useState(false)
const currentRepositoryId = useWatch({ control: form.control, name }) as string | undefined
const selectedRepository = repositories.find((repo) => repo.id === currentRepositoryId)
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItemLayout id={name} label={label} layout={layout} description={description}>
{gitHubAuthorization === null && !currentRepositoryId ? (
<FormControl>
<ConnectGitHubButton id={name} onConnectClick={onConnectClick} refetch={refetch} />
</FormControl>
) : (
<Popover open={isRepoSelectorOpen} onOpenChange={setIsRepoSelectorOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
id={name}
type="button"
className="justify-start h-[34px] w-full [&>div:last-child]:ml-auto"
disabled={disabled || isLoading}
loading={!!gitHubAuthorization && isLoading}
iconRight={<ChevronDown />}
>
{selectedRepository?.name ||
selectedRepositoryName ||
(isLoading ? 'Loading GitHub repositories...' : placeholder)}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" side="bottom" align="start" sameWidthAsTrigger>
<Command>
<CommandInput placeholder="Search repositories..." />
<CommandList className="!max-h-[220px]">
<CommandEmpty>No repositories found.</CommandEmpty>
{repositories.length > 0 ? (
<CommandGroup>
{repositories.map((repo) => (
<CommandItem
key={repo.id}
value={`${repo.name.replaceAll('"', '')}-${repo.id}`}
onSelect={() => {
field.onChange(repo.id)
if (installationIdField !== undefined) {
form.setValue(installationIdField, repo.installation_id as never, {
shouldDirty: true,
})
}
if (repositoryNameField !== undefined) {
form.setValue(repositoryNameField, repo.name as never, {
shouldDirty: true,
})
}
onRepositorySelect?.(repo)
setIsRepoSelectorOpen(false)
}}
>
<span className="truncate" title={repo.name}>
{repo.name}
</span>
</CommandItem>
))}
</CommandGroup>
) : null}
<CommandGroup>
<CommandItem
className="flex gap-2 items-center cursor-pointer"
onSelect={() => {
setIsRepoSelectorOpen(false)
openInstallGitHubIntegrationWindow('install', refetch)
}}
>
<PlusIcon size={16} />
Add GitHub Repositories
</CommandItem>
</CommandGroup>
{hasPartialResponseDueToSSO && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
className="flex gap-2 items-start cursor-pointer"
onSelect={() => {
setIsRepoSelectorOpen(false)
openInstallGitHubIntegrationWindow('authorize', refetch)
}}
>
<RefreshCw size={16} className="mt-0.5 shrink-0" />
<div className="text-xs text-foreground-light">
Re-authorize GitHub with SSO to show all repositories
</div>
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</FormItemLayout>
)}
/>
)
}