mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +08:00
## 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 -->
316 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|