Files
supabase/apps/studio/components/ui/OrganizationProjectSelector.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

307 lines
8.8 KiB
TypeScript

import { keepPreviousData } from '@tanstack/react-query'
import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks'
import { ChevronsUpDown, HelpCircle } from 'lucide-react'
import { ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react'
import {
Button,
cn,
Command,
CommandGroup,
CommandInput,
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
ScrollArea,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { EmbeddedProjectList } from './OrganizationProjectSelector/EmbeddedProjectList'
import { ProjectCommandItem } from './OrganizationProjectSelector/ProjectCommandItem'
import {
OrgProject,
useOrgProjectsInfiniteQuery,
} from '@/data/projects/org-projects-infinite-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
interface OrganizationProjectSelectorSelectorProps {
slug?: string
open?: boolean
selectedRef?: string | null
searchPlaceholder?: string
sameWidthAsTrigger?: boolean
checkPosition?: 'right' | 'left'
setOpen?: (value: boolean) => void
renderRow?: (project: OrgProject) => ReactNode
renderTrigger?: ({
isLoading,
project,
listboxId,
open,
}: {
isLoading: boolean
project?: OrgProject
listboxId: string
open: boolean
}) => ReactNode
renderActions?: (setOpen: (value: boolean) => void, options?: { embedded?: boolean }) => ReactNode
onSelect?: (project: OrgProject) => void
getItemHref?: (project: OrgProject) => string
onInitialLoad?: (projects: OrgProject[]) => void
isOptionDisabled?: (project: OrgProject) => boolean
fetchOnMount?: boolean
modal?: boolean
/** When true, render only the command list (no popover/trigger). For use inside sheet or popover. */
embedded?: boolean
className?: string
}
export const OrganizationProjectSelector = ({
slug: _slug,
open: _open,
setOpen: _setOpen,
selectedRef,
searchPlaceholder = 'Find project...',
sameWidthAsTrigger = false,
checkPosition = 'right',
renderRow,
renderTrigger,
renderActions,
onSelect,
getItemHref,
onInitialLoad,
isOptionDisabled,
fetchOnMount = false,
modal = false,
embedded = false,
className,
}: OrganizationProjectSelectorSelectorProps) => {
const { data: organization } = useSelectedOrganizationQuery()
const slug = _slug ?? organization?.slug
const [openInternal, setOpenInternal] = useState(false)
const open = _open ?? openInternal
const setOpen = _setOpen ?? setOpenInternal
const listboxId = useId()
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 500)
const scrollRootRef = useRef<HTMLDivElement | null>(null)
const [sentinelRef, entry] = useIntersectionObserver({
root: scrollRootRef.current,
threshold: 0,
rootMargin: '0px',
})
const {
data,
error: projectsError,
isLoading: isLoadingProjects,
isError: isErrorProjects,
isSuccess: isSuccessProjects,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useOrgProjectsInfiniteQuery(
{ slug, search: search.length === 0 ? search : debouncedSearch },
{ enabled: fetchOnMount || open, placeholderData: keepPreviousData }
)
const projects = useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || []
const selectedProject = projects.find((p) => p.ref === selectedRef)
useEffect(() => {
if (
!isLoadingProjects &&
!isFetching &&
entry?.isIntersecting &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage()
}
}, [
entry?.isIntersecting,
hasNextPage,
isFetching,
isFetchingNextPage,
isLoadingProjects,
fetchNextPage,
])
useEffect(() => {
// isLoadingProjects is true only during initial load. If the variables for the query change (slug), isLoadingProjects
// will be true again.
if (!isLoadingProjects && isSuccessProjects) {
onInitialLoad?.(projects)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoadingProjects, isSuccessProjects])
function renderListContent() {
if (isLoadingProjects) {
return (
<>
<div className="px-2 py-1">
<ShimmeringLoader className="py-2" />
</div>
<div className="px-2 py-1 w-4/5">
<ShimmeringLoader className="py-2" />
</div>
</>
)
}
if (isErrorProjects) {
return (
<div className="flex items-center gap-x-2 py-3 justify-center">
<p className="text-xs text-foreground-lighter">Failed to retrieve projects</p>
<Tooltip>
<TooltipTrigger>
<HelpCircle size={14} />
</TooltipTrigger>
<TooltipContent side="bottom">Error: {projectsError?.message}</TooltipContent>
</Tooltip>
</div>
)
}
if (search.length > 0 && projects.length === 0) {
return (
<p className="text-xs text-center text-foreground-lighter py-3">
No projects found based on your search
</p>
)
}
if (projects.length === 0) {
return <p className="text-xs text-center text-foreground-lighter py-3">No projects found</p>
}
if (embedded) {
return (
<EmbeddedProjectList
projects={projects}
selectedRef={selectedRef ?? undefined}
onSelect={onSelect}
onClose={() => setOpen(false)}
getItemHref={getItemHref}
renderRow={renderRow}
checkPosition={checkPosition}
isOptionDisabled={isOptionDisabled}
sentinelRef={sentinelRef}
hasNextPage={!!hasNextPage}
/>
)
}
return (
<ScrollArea className={(projects || []).length > 7 ? 'h-full md:h-[210px]' : ''}>
{projects?.map((project) => (
<ProjectCommandItem
key={project.ref}
project={project}
selectedRef={selectedRef ?? undefined}
onSelect={onSelect}
onClose={() => setOpen(false)}
href={getItemHref?.(project)}
renderRow={renderRow}
checkPosition={checkPosition}
isOptionDisabled={isOptionDisabled}
/>
))}
<div ref={sentinelRef} className="h-1 -mt-1" />
{hasNextPage && (
<div className="px-2 py-1">
<ShimmeringLoader className="py-2" />
</div>
)}
</ScrollArea>
)
}
const commandContent = (
<Command
shouldFilter={false}
className={cn(className, embedded && 'flex flex-col flex-1 min-h-0 overflow-hidden')}
>
{embedded && !!renderActions && (
<div className="flex items-center gap-2 shrink-0 border-b p-2">
{renderActions(setOpen, { embedded: true })}
</div>
)}
<CommandInput
showResetIcon
value={search}
onValueChange={setSearch}
placeholder={searchPlaceholder}
handleReset={() => setSearch('')}
wrapperClassName={embedded ? 'shrink-0 border-b' : undefined}
className="text-base sm:text-sm"
/>
<CommandList
className={
embedded
? 'flex-1 min-h-0 overflow-y-auto overflow-x-hidden max-h-none!'
: 'max-h-none md:max-h-[300px] overflow-y-auto overflow-x-hidden'
}
>
<CommandGroup className={embedded ? 'flex-1 min-h-0 overflow-hidden' : ''}>
{renderListContent()}
</CommandGroup>
{!!renderActions && !embedded && (
<>
<div className="h-px bg-border-overlay -mx-1 shrink-0" />
{renderActions(setOpen)}
</>
)}
</CommandList>
</Command>
)
if (embedded) {
return commandContent
}
return (
<Popover open={open} onOpenChange={setOpen} modal={modal}>
<PopoverTrigger asChild>
{renderTrigger ? (
renderTrigger({
isLoading: isLoadingProjects || isFetching,
project: selectedProject,
listboxId,
open,
})
) : (
<Button
block
variant="default"
role="combobox"
size="small"
aria-expanded={open}
aria-controls={listboxId}
className="justify-between"
iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />}
>
{isLoadingProjects || isFetching ? (
<ShimmeringLoader className="w-44 py-2" />
) : (
(selectedProject?.name ?? 'Select a project')
)}
</Button>
)}
</PopoverTrigger>
<PopoverContent
id={listboxId}
sameWidthAsTrigger={sameWidthAsTrigger}
className="p-0"
side="bottom"
align="start"
>
{commandContent}
</PopoverContent>
</Popover>
)
}