Files
supabase/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinkerComponents.tsx
Danny White 29ad86558c fix(studio): make menu links reliable (#49584)
## What kind of change does this PR introduce?

Bug fix. Resolves
[FE-4192](https://linear.app/supabase/issue/FE-4192/org-and-project-selectors-sometimes-dont-register-selections).

## What is the current behavior?

Navigation actions sometimes nest links inside command or dropdown menu
items. Closing the menu during selection can prevent the nested link
navigation from registering.

## What is the new behavior?

- Adds a documented Studio CommandItemLink composition that wraps
command items with their navigation link.
- Migrates all Studio command-item links, including organisation,
project, function, database, branch, and integration actions.
- Uses the dropdown menu asChild composition for both
infrastructure-diagram Manage replica actions.
- Preserves native link behaviour and leaves disabled command items
non-navigable.

## To test

- [ ] [Organisation and project
selectors](https://studio-staging-git-dnywh-fe-4192-selector-links-supabase.vercel.app/dashboard/org):
open the organisation selector and try an organisation, All
Organizations, and New organization. Open a project, then use the
project selector to switch projects and open New project. Confirm every
action navigates on the first click.
- [ ] [Branch
selector](https://studio-staging-git-dnywh-fe-4192-selector-links-supabase.vercel.app/dashboard/project/_):
in a project with branching enabled, open the branch selector. Switch
branches and select Manage branches. Confirm both navigate on the first
click.
- [ ] [Database
selector](https://studio-staging-git-dnywh-fe-4192-selector-links-supabase.vercel.app/dashboard/project/_/observability/query-performance):
open the Source selector. Switch between the primary database and a read
replica if available, then select Create a new read replica. Confirm
selections apply and the footer action navigates on the first click.
- [ ] [Function
selector](https://studio-staging-git-dnywh-fe-4192-selector-links-supabase.vercel.app/dashboard/project/_/auth/hooks):
select Add a new hook, choose a hook, select Postgres, then open the
Postgres function selector and select New function. Confirm it navigates
on the first click.
- [ ] [Infrastructure
diagram](https://studio-staging-git-dnywh-fe-4192-selector-links-supabase.vercel.app/dashboard/project/_/settings/infrastructure):
for a project with a read replica, select Manage replica from both
diagram variants. Confirm the replica settings open on the first click.
- [ ] On any navigational row above, modifier-click and confirm native
link behaviour is preserved.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added consistent link navigation across organization, project, branch,
function, replica, and integration menus.
* Added project-specific destinations to organization and project
selectors.
* Preserved disabled-item behavior while improving accessible
command-menu link semantics.

* **Bug Fixes**
* Improved navigation and menu-closing behavior for command items and
dropdown actions.

* **Tests**
* Added coverage for link destinations, accessibility roles, disabled
states, and route preservation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-26 16:59:44 +08:00

316 lines
10 KiB
TypeScript

import { useParams } from 'common'
import { Check, ChevronDown, Plus, PlusIcon } from 'lucide-react'
import Link from 'next/link'
import { HTMLAttributes } from 'react'
import {
Badge,
Button,
cn,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
Popover,
PopoverContent,
PopoverTrigger,
} from 'ui'
import { Project, type ForeignProject, type ProjectLinkerProps } from './VercelGithub.types'
import { CommandItemLink } from '@/components/ui/CommandItemLink'
import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { BASE_PATH } from '@/lib/constants'
import { openInstallGitHubIntegrationWindow } from '@/lib/github'
export const Panel = ({ children, className, ...props }: HTMLAttributes<HTMLDivElement>) => {
return (
<div
className={cn(
'flex-1 min-w-0 flex flex-col grow gap-6 px-5 mx-auto w-full justify-center items-center',
className
)}
{...props}
>
{children}
</div>
)
}
export const ForeignProjectSelector = ({
open,
mode,
variant,
choosePrompt,
selectedForeignProject,
loadingForeignProjects,
foreignProjects,
integrationIcon,
onOpenChange,
setForeignProjectId,
getForeignProjectIcon,
}: {
open: boolean
selectedForeignProject?: ForeignProject
setForeignProjectId: (id: string) => void
onOpenChange: (val: boolean) => void
} & Pick<
ProjectLinkerProps,
| 'mode'
| 'variant'
| 'choosePrompt'
| 'loadingForeignProjects'
| 'foreignProjects'
| 'getForeignProjectIcon'
| 'integrationIcon'
>) => {
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
variant="default"
block
disabled={loadingForeignProjects}
loading={loadingForeignProjects}
className={cn(
variant === 'interstitial' ? 'h-[34px] justify-between' : 'justify-start h-[34px]'
)}
icon={
variant === 'default' ? (
<div>
{selectedForeignProject
? (getForeignProjectIcon?.(selectedForeignProject) ?? integrationIcon)
: integrationIcon}
</div>
) : undefined
}
iconRight={
<span className="grow flex justify-end">
<ChevronDown />
</span>
}
>
<span className="truncate">
{(selectedForeignProject && selectedForeignProject.name) ?? choosePrompt}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" side="bottom" align="center" sameWidthAsTrigger>
<Command>
<CommandInput placeholder="Search for a project" />
<CommandList className="max-h-[170px]!">
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{foreignProjects.map((project, i) => {
return (
<CommandItem
key={project.id}
value={`${project.name.replaceAll('"', '')}-${i}`}
className="flex gap-2 items-center"
onSelect={() => {
if (project.id) setForeignProjectId(project.id)
onOpenChange(false)
}}
>
<div>{getForeignProjectIcon?.(project) ?? integrationIcon}</div>
<span className="truncate" title={project.name}>
{project.name}
</span>
</CommandItem>
)
})}
{foreignProjects.length === 0 && <CommandEmpty>No results found.</CommandEmpty>}
</CommandGroup>
{mode === 'GitHub' && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
className="flex gap-2 items-center cursor-pointer"
onSelect={() => openInstallGitHubIntegrationWindow('install')}
>
<PlusIcon size={16} />
Add GitHub Repositories
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
export const SupabaseProjectSelector = ({
open,
variant,
slug,
defaultSupabaseProject,
selectedSupabaseProject,
loadingSupabaseProjects,
setOpen,
setSelectedSupabaseProject,
}: {
open: boolean
selectedSupabaseProject?: Project
loadingSupabaseProjects: boolean
setOpen: (val: boolean) => void
setSelectedSupabaseProject: (project: Project) => void
} & Pick<ProjectLinkerProps, 'slug' | 'variant' | 'defaultSupabaseProject'>) => {
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const projectCreationEnabled = useIsFeatureEnabled('projects:create')
return (
<OrganizationProjectSelector
sameWidthAsTrigger
open={open}
setOpen={setOpen}
slug={slug}
selectedRef={selectedSupabaseProject?.ref}
onSelect={(project) => {
setSelectedSupabaseProject(project)
setOpen(false)
}}
renderRow={(project) => {
return (
<div className={cn('w-full flex items-center justify-between')}>
<div className="flex items-center gap-x-2">
{variant === 'default' && (
<div className="bg-white shadow-sm border rounded-sm p-1 w-6 h-6 flex justify-center items-center">
<img src={`${BASE_PATH}/img/supabase-logo.svg`} alt="Supabase" className="w-4" />
</div>
)}
<p>{project.name}</p>
{project.status === 'INACTIVE' && <Badge>Paused</Badge>}
{project.status === 'GOING_DOWN' && <Badge>Pausing</Badge>}
</div>
{project.ref === selectedSupabaseProject?.ref && <Check size={16} />}
</div>
)
}}
renderTrigger={() => {
return (
<Button
variant="default"
block
disabled={defaultSupabaseProject !== undefined || loadingSupabaseProjects}
loading={loadingSupabaseProjects}
className="justify-between h-[34px]"
iconRight={
defaultSupabaseProject === undefined ? (
<span className="grow flex justify-end">
<ChevronDown />
</span>
) : null
}
>
<div className="flex items-center gap-x-2">
{variant === 'default' && (
<div className="bg-white shadow-sm border rounded-sm p-1 w-6 h-6 flex justify-center items-center">
<img src={`${BASE_PATH}/img/supabase-logo.svg`} alt="Supabase" className="w-4" />
</div>
)}
<span className="truncate">
{selectedSupabaseProject ? selectedSupabaseProject.name : 'Choose Supabase project'}
</span>
</div>
</Button>
)
}}
renderActions={() => {
return (
projectCreationEnabled && (
<CommandGroup>
<CommandItemLink
href={`/new/${selectedOrganization?.slug}`}
className="cursor-pointer w-full gap-2"
onSelect={() => setOpen(false)}
>
<Plus size={14} strokeWidth={1.5} />
<p>Create a new project</p>
</CommandItemLink>
</CommandGroup>
)
)
}}
/>
)
}
export const ActionButtons = ({
slug,
mode,
variant,
showCreateProject,
connectDisabled,
isLoading,
foreignProjectId,
onCreateConnections,
onSkip,
}: {
showCreateProject: boolean
connectDisabled: boolean
foreignProjectId: string | undefined
onCreateConnections: () => void
} & Pick<ProjectLinkerProps, 'slug' | 'variant' | 'mode' | 'onSkip' | 'isLoading'>) => {
const { next, externalId, currentProjectId } = useParams()
const organizationSlug = slug
const vercelProjectId = foreignProjectId ?? currentProjectId
// Deploy-button create is only for the install interstitial; settings side panels use /new.
const newProjectURL =
mode === 'Vercel' && variant === 'interstitial' && organizationSlug
? `/integrations/vercel/${organizationSlug}/deploy-button/new-project?${new URLSearchParams({
...(next ? { next } : {}),
...(vercelProjectId ? { currentProjectId: vercelProjectId } : {}),
...(externalId ? { externalId } : {}),
})}`
: `/new/${organizationSlug}`
return (
<div
className={cn('flex w-full gap-2', variant === 'interstitial' ? 'flex-col' : 'justify-end')}
>
{showCreateProject ? (
<Button
asChild
size={variant === 'interstitial' ? undefined : 'medium'}
variant={variant === 'interstitial' ? 'primary' : 'default'}
block={variant === 'interstitial'}
className={variant === 'default' ? 'self-end' : undefined}
loading={isLoading}
>
<Link href={newProjectURL}>Create project</Link>
</Button>
) : (
<Button
size={variant === 'interstitial' ? undefined : 'medium'}
variant={variant === 'interstitial' ? 'primary' : 'default'}
block={variant === 'interstitial'}
className={variant === 'default' ? 'self-end' : undefined}
onClick={onCreateConnections}
loading={isLoading}
disabled={connectDisabled}
>
Connect project
</Button>
)}
{onSkip !== undefined && (
<Button
size={variant === 'interstitial' ? undefined : 'medium'}
variant={variant === 'interstitial' ? 'text' : 'default'}
block={variant === 'interstitial'}
onClick={() => {
onSkip()
}}
>
Skip
</Button>
)}
</div>
)
}