unified org project branch selector

This commit is contained in:
Francesco Sansalvadore committed 2026-03-31 17:24:02 +02:00
1 parent 62dbdeede8
commit 8cd061f076
8 files changed
+500 -94

No files matched your search

@@ -39,6 +39,7 @@ export const BranchDropdown = ({
} = useBranchesQuery({ projectRef }, { enabled: Boolean(projectDetails) })
const isBranchingEnabled = projectDetails?.is_branch_enabled === true
const selectedBranch = branches?.find((branch) => branch.project_ref === ref)
const defaultMainBranch = {
@@ -11,6 +11,7 @@ export interface OrgCommandItemProps {
hasRouteSlug: boolean
onClose: () => void
compactPadding?: boolean
onSelectOrganization?: (org: Organization) => void
}
export function OrgCommandItem({
@@ -20,6 +21,7 @@ export function OrgCommandItem({
hasRouteSlug,
onClose,
compactPadding = false,
onSelectOrganization,
}: OrgCommandItemProps) {
const href = hasRouteSlug ? routePathname.replace('[slug]', org.slug) : `/org/${org.slug}`
@@ -28,21 +30,42 @@ export function OrgCommandItem({
key={org.slug}
value={`${org.name.replaceAll('"', '')} - ${org.slug}`}
className="cursor-pointer w-full"
onSelect={() => onClose()}
onSelect={() => {
if (onSelectOrganization) {
onSelectOrganization(org)
return
}
onClose()
}}
>
<Link
href={href}
className={cn(
'w-full flex items-center justify-between text-sm md:text-xs',
!compactPadding && 'p-0.5'
)}
>
<div className="flex items-center gap-2">
<span>{org.name}</span>
<PartnerIcon organization={org} />
{onSelectOrganization ? (
<div
className={cn(
'w-full flex items-center justify-between text-sm md:text-xs',
!compactPadding && 'p-0.5'
)}
>
<div className="flex items-center gap-2">
<span>{org.name}</span>
<PartnerIcon organization={org} />
</div>
{org.slug === selectedSlug && <Check size={16} />}
</div>
{org.slug === selectedSlug && <Check size={16} />}
</Link>
) : (
<Link
href={href}
className={cn(
'w-full flex items-center justify-between text-sm md:text-xs',
!compactPadding && 'p-0.5'
)}
>
<div className="flex items-center gap-2">
<span>{org.name}</span>
<PartnerIcon organization={org} />
</div>
{org.slug === selectedSlug && <Check size={16} />}
</Link>
)}
</CommandItem_Shadcn_>
)
}
@@ -1,4 +1,3 @@
import PartnerIcon from 'components/ui/PartnerIcon'
import { Plus } from 'lucide-react'
import Link from 'next/link'
import type { Organization } from 'types'
@@ -26,6 +25,7 @@ export interface OrganizationDropdownCommandContentProps {
hasRouteSlug: boolean
organizationCreationEnabled: boolean
onClose: () => void
onSelectOrganization?: (org: Organization) => void
}
export function OrganizationDropdownCommandContent({
@@ -37,6 +37,7 @@ export function OrganizationDropdownCommandContent({
hasRouteSlug,
organizationCreationEnabled,
onClose,
onSelectOrganization,
}: OrganizationDropdownCommandContentProps) {
const orgList = (
<>
@@ -49,6 +50,7 @@ export function OrganizationDropdownCommandContent({
hasRouteSlug={hasRouteSlug}
onClose={onClose}
compactPadding={!embedded}
onSelectOrganization={onSelectOrganization}
/>
))}
</>
@@ -56,7 +58,9 @@ export function OrganizationDropdownCommandContent({
if (embedded) {
return (
<Command_Shadcn_ className={cn(className, 'flex flex-col flex-1 min-h-0 overflow-hidden')}>
<Command_Shadcn_
className={cn(className, 'flex flex-col flex-1 min-h-0 w-full overflow-hidden')}
>
<div className="flex items-center gap-2 shrink-0 border-b p-2">
<Button type="text" block size="small" asChild>
<Link
@@ -90,24 +94,35 @@ export function OrganizationDropdownCommandContent({
wrapperClassName="shrink-0"
className="text-base sm:text-sm"
/>
<CommandList_Shadcn_ className="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 !max-h-none">
<CommandList_Shadcn_ className="flex flex-col flex-1 min-h-0 p-1 max-h-none w-full">
<CommandEmpty_Shadcn_>No organizations found</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_ className="min-h-0">{orgList}</CommandGroup_Shadcn_>
<ScrollArea className="flex-1 min-h-0">
<CommandGroup_Shadcn_ className="min-h-0">{orgList}</CommandGroup_Shadcn_>
</ScrollArea>
</CommandList_Shadcn_>
</Command_Shadcn_>
)
}
return (
<Command_Shadcn_ className={className}>
<CommandInput_Shadcn_ placeholder="Find organization..." />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>No organizations found</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
<ScrollArea className={(organizations || []).length > 7 ? 'md:h-[210px]' : ''}>
{orgList}
<Command_Shadcn_
className={cn('w-full flex flex-col flex-1 min-h-0 overflow-hidden', className)}
>
<CommandInput_Shadcn_
placeholder="Find organization..."
wrapperClassName="shrink-0 border-b"
/>
<div className="flex-1 min-h-0 w-full">
<CommandList_Shadcn_ className="h-full max-h-none w-full overflow-hidden">
<CommandEmpty_Shadcn_>No organizations found</CommandEmpty_Shadcn_>
<ScrollArea className="h-full">
<CommandGroup_Shadcn_ className="min-h-0">{orgList}</CommandGroup_Shadcn_>
</ScrollArea>
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</div>
<div className="shrink-0">
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_>
<CommandItem_Shadcn_ className="cursor-pointer w-full" onSelect={() => onClose()}>
@@ -129,7 +144,7 @@ export function OrganizationDropdownCommandContent({
</CommandGroup_Shadcn_>
</>
)}
</CommandList_Shadcn_>
</div>
</Command_Shadcn_>
)
}
@@ -1,4 +1,4 @@
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common'
import {
useIsBranching2Enabled,
useIsFloatingMobileToolbarEnabled,
@@ -30,6 +30,8 @@ import { ReactNode, useMemo } from 'react'
import { Badge, cn } from 'ui'
import { CommandMenuTriggerInput } from 'ui-patterns'
import { OrgSelector } from '../NavigationBar/OrgSelector'
import { ProjectBranchSelector } from '../NavigationBar/ProjectBranchSelector'
import { BreadcrumbsView } from './BreadcrumbsView'
import { FeedbackDropdown } from './FeedbackDropdown/FeedbackDropdown'
import { HomeIcon } from './HomeIcon'
@@ -68,6 +70,7 @@ export const LayoutHeader = ({
headerTitle,
backToDashboardURL,
}: LayoutHeaderProps) => {
const showUnifiedOrgProjectSelector = useFlag('enableUnifiedOrgprojectbranchSelector')
const router = useRouter()
const { ref: projectRef, slug } = useParams()
const { data: selectedProject } = useSelectedProjectQuery()
@@ -101,6 +104,7 @@ export const LayoutHeader = ({
// show org selection if we are on a project page or on a explicit org route
const showOrgSelection = slug || (selectedOrganization && projectRef)
const isProjectScope = projectRef
return (
<>
@@ -147,14 +151,21 @@ export const LayoutHeader = ({
<div className="hidden md:flex items-center text-sm">
<HomeIcon />
<div className="flex items-center md:pl-2">
{showOrgSelection && IS_PLATFORM ? (
{showOrgSelection && IS_PLATFORM && !showUnifiedOrgProjectSelector ? (
<>
<LayoutHeaderDivider className="hidden md:block" />
<OrganizationDropdown />
</>
) : null}
<AnimatePresence>
{projectRef && (
{IS_PLATFORM &&
showUnifiedOrgProjectSelector &&
(isProjectScope ? (
<ProjectBranchSelector />
) : showOrgSelection ? (
<OrgSelector />
) : null)}
{projectRef && !showUnifiedOrgProjectSelector && (
<motion.div
className="flex items-center"
initial={{ opacity: 0, x: -20 }}
@@ -76,7 +76,7 @@ export function OrgSelector() {
</div>
<ChevronsUpDown
strokeWidth={1}
className="ml-auto text-foreground-light md:hidden md:group-hover:block !w-4 !h-4"
className="ml-auto text-foreground-light md:opacity-0 md:group-hover:opacity-100 transition-opacity !w-4 !h-4"
/>
</SidebarMenuButton>
)
@@ -1,19 +1,346 @@
import { BranchDropdown } from 'components/layouts/AppLayout/BranchDropdown'
import { OrganizationDropdown } from 'components/layouts/AppLayout/OrganizationDropdown'
import { ProjectDropdown } from 'components/layouts/AppLayout/ProjectDropdown'
import type { ParsedUrlQuery } from 'querystring'
import { useParams } from 'common'
import { OrganizationDropdownCommandContent } from 'components/layouts/AppLayout/OrganizationDropdownCommandContent'
import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector'
import { useBranchesQuery } from 'data/branches/branches-query'
import type { Branch } from 'data/branches/branches-query'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { IS_PLATFORM } from 'lib/constants'
import { Check, ListTree, MessageCircle, Plus, Shield } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { useAppStateSnapshot } from 'state/app-state'
import type { Organization } from 'types'
import {
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
CommandSeparator_Shadcn_,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
const embeddedClassName = 'bg-transparent border-0 shadow-none min-h-0 flex-1'
const BRANCHING_GITHUB_DISCUSSION_LINK = 'https://github.com/orgs/supabase/discussions/18937'
export interface ProjectBranchSelectorPopoverProps {
onClose: () => void
}
export function ProjectBranchSelectorPopover({ onClose }: ProjectBranchSelectorPopoverProps) {
const router = useRouter()
const { ref, slug: routeSlug } = useParams()
const snap = useAppStateSnapshot()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { data: organizations } = useOrganizationsQuery()
const { data: project } = useSelectedProjectQuery()
const projectCreationEnabled = useIsFeatureEnabled('projects:create')
const organizationCreationEnabled = useIsFeatureEnabled('organizations:create')
const isBranch = project?.parentRef !== project?.ref
const { data: parentProject } = useProjectDetailQuery(
{ ref: project?.parent_project_ref },
{ enabled: isBranch }
)
const displayProject = parentProject ?? project
const parentRef = project?.parent_project_ref || ref
const [activeOrganizationSlug, setActiveOrganizationSlug] = useState<string | undefined>(
selectedOrganization?.slug
)
useEffect(() => {
setActiveOrganizationSlug(selectedOrganization?.slug)
}, [selectedOrganization?.slug])
const { data: branches, isSuccess: isBranchesSuccess } = useBranchesQuery(
{ projectRef: parentRef },
{ enabled: Boolean(project) }
)
const isBranchingEnabled = project?.is_branch_enabled === true
const selectedBranch = branches?.find((b) => b.project_ref === ref)
const mainBranch = branches?.find((b) => b.is_default)
const restOfBranches = branches
?.filter((b) => !b.is_default)
?.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
const defaultMainBranch = {
id: 'main',
name: 'main',
project_ref: parentRef ?? ref ?? '',
is_default: true,
} as unknown as Branch
const sortedBranches =
branches && branches.length > 0
? mainBranch
? [mainBranch].concat(restOfBranches ?? [])
: (restOfBranches ?? [])
: [defaultMainBranch]
const branchList = isBranchingEnabled ? sortedBranches : [defaultMainBranch]
if (!IS_PLATFORM) return null
if (!displayProject) return null
return (
<div className="flex divide-x h-[320px]">
<OrganizationDropdown embedded className={embeddedClassName} onClose={onClose} />
<ProjectDropdown embedded className={embeddedClassName} onClose={onClose} />
<BranchDropdown embedded className={embeddedClassName} onClose={onClose} />
<OrganizationColumn
routePathname={router.pathname}
hasRouteSlug={Boolean(routeSlug)}
organizations={organizations ?? []}
selectedSlug={activeOrganizationSlug ?? selectedOrganization?.slug}
onSelect={(slug) => setActiveOrganizationSlug(slug)}
organizationCreationEnabled={organizationCreationEnabled}
onClose={onClose}
/>
<ProjectColumn
selectedRef={ref}
onClose={onClose}
organizationSlug={activeOrganizationSlug ?? selectedOrganization?.slug}
projectCreationEnabled={projectCreationEnabled}
/>
<BranchColumn
branches={branchList}
selectedBranch={selectedBranch}
isBranchingEnabled={isBranchingEnabled}
isBranchesLoaded={isBranchesSuccess}
projectRef={parentRef ?? ref ?? ''}
onSelect={(branch) => {
const sanitizedRoute = sanitizeRoute(router.route, router.query)
const href =
sanitizedRoute?.replace('[ref]', branch.project_ref) ?? `/project/${branch.project_ref}`
onClose()
router.push(href)
}}
onCreateBranch={() => {
onClose()
snap.setShowCreateBranchModal(true)
}}
onManageBranches={() => {
onClose()
router.push(`/project/${ref}/branches`)
}}
onClose={onClose}
/>
</div>
)
}
function sanitizeRoute(route: string, routerQueries: ParsedUrlQuery) {
const queryArray = Object.entries(routerQueries)
if (queryArray.length > 1) {
const isStorageBucketRoute = 'bucketId' in routerQueries
const isSecurityAdvisorRoute = 'preset' in routerQueries
return route
.split('/')
.slice(0, isStorageBucketRoute || isSecurityAdvisorRoute ? 5 : 4)
.join('/')
}
return route
}
function OrganizationColumn({
routePathname,
hasRouteSlug,
organizations,
selectedSlug,
onSelect,
organizationCreationEnabled,
onClose,
}: {
routePathname: string
hasRouteSlug: boolean
organizations: Array<{ slug: string; name: string }>
selectedSlug?: string
onSelect: (slug: string) => void
organizationCreationEnabled: boolean
onClose: () => void
}) {
return (
<OrganizationDropdownCommandContent
embedded={false}
className="bg-transparent border-0 shadow-none min-h-0 flex-1 flex flex-col overflow-hidden rounded-none"
organizations={organizations as Organization[]}
selectedSlug={selectedSlug}
routePathname={routePathname}
hasRouteSlug={hasRouteSlug}
organizationCreationEnabled={organizationCreationEnabled}
onClose={onClose}
onSelectOrganization={(org) => onSelect(org.slug)}
/>
)
}
function ProjectColumn({
selectedRef,
onClose,
organizationSlug,
projectCreationEnabled,
}: {
selectedRef?: string
onClose: () => void
organizationSlug?: string
projectCreationEnabled: boolean
}) {
const router = useRouter()
return (
<div className="flex flex-col flex-1 min-w-0">
<OrganizationProjectSelector
renderOnlyContent
fetchOnMount
slug={organizationSlug}
selectedRef={selectedRef ?? null}
className="bg-transparent border-0 shadow-none min-h-0 flex-1 flex flex-col overflow-hidden rounded-none"
onSelect={(project) => {
const sanitizedRoute = sanitizeRoute(router.route, router.query)
const href = sanitizedRoute?.replace('[ref]', project.ref) ?? `/project/${project.ref}`
onClose()
router.push(href)
}}
renderActions={() =>
projectCreationEnabled && organizationSlug ? (
<CommandGroup_Shadcn_>
<CommandItem_Shadcn_
className="cursor-pointer w-full"
onSelect={() => {
onClose()
router.push(`/new/${organizationSlug}`)
}}
>
<Link
href={`/new/${organizationSlug}`}
onClick={onClose}
className="w-full flex items-center gap-2"
>
<Plus size={14} strokeWidth={1.5} />
<p>New project</p>
</Link>
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
) : null
}
/>
</div>
)
}
function BranchColumn({
branches,
selectedBranch,
isBranchingEnabled,
isBranchesLoaded,
projectRef,
onSelect,
onCreateBranch,
onManageBranches,
onClose,
}: {
branches: Branch[]
selectedBranch?: Branch
isBranchingEnabled: boolean
isBranchesLoaded: boolean
projectRef: string
onSelect: (branch: Branch) => void
onCreateBranch: () => void
onManageBranches: () => void
onClose: () => void
}) {
const [search, setSearch] = useState('')
const lowerSearch = search.toLowerCase()
const filteredBranches = branches.filter((b) => b.name.toLowerCase().includes(lowerSearch))
return (
<div className="flex flex-col flex-1 min-w-0">
<Command_Shadcn_ shouldFilter={false} className="min-h-0 flex-1 rounded-none border-0">
<CommandInput_Shadcn_
value={search}
onValueChange={setSearch}
placeholder="Find branch..."
showResetIcon
handleReset={() => setSearch('')}
/>
<CommandList_Shadcn_ className="max-h-none flex-1 overflow-y-auto">
{!isBranchesLoaded ? (
<div className="space-y-1 p-2">
<ShimmeringLoader className="py-2" />
<ShimmeringLoader className="py-2 w-4/5" />
</div>
) : (
<>
<CommandEmpty_Shadcn_>No branches found</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
{filteredBranches.map((branch) => {
const isSelected =
branch.id === selectedBranch?.id || (!selectedBranch && branch.is_default)
return (
<CommandItem_Shadcn_
key={branch.id}
value={branch.name.replaceAll('"', '')}
className="cursor-pointer w-full"
onSelect={() => onSelect(branch)}
>
<span className="truncate flex flex-1 items-center gap-1.5">
{branch.is_default && (
<Shield size={12} className="text-amber-900 shrink-0" />
)}
{branch.name}
</span>
{isSelected && <Check size={14} className="shrink-0 ml-2" />}
</CommandItem_Shadcn_>
)
})}
</CommandGroup_Shadcn_>
</>
)}
</CommandList_Shadcn_>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_ className="space-y-0.5">
<CommandItem_Shadcn_ className="cursor-pointer w-full" onSelect={() => onClose()}>
<Link
href={BRANCHING_GITHUB_DISCUSSION_LINK}
target="_blank"
className="flex items-center gap-2 w-full"
>
<MessageCircle size={14} strokeWidth={1.5} />
<p>Branching feedback</p>
</Link>
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_ className=" space-y-0.5">
{isBranchingEnabled && (
<CommandItem_Shadcn_ className="cursor-pointer w-full" onSelect={() => onClose()}>
<button
onClick={onManageBranches}
type="button"
className="flex items-center gap-2 w-full"
>
<ListTree size={14} strokeWidth={1.5} />
<p>Manage branches</p>
</button>
</CommandItem_Shadcn_>
)}
<CommandItem_Shadcn_ className="cursor-pointer w-full" onSelect={() => onClose()}>
<button
onClick={onCreateBranch}
type="button"
className="flex items-center gap-2 w-full"
>
<Plus size={14} strokeWidth={1.5} />
<p>Create branch</p>
</button>
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
</Command_Shadcn_>
</div>
)
}
@@ -1,6 +1,6 @@
import { ChevronsUpDown, GitBranch } from 'lucide-react'
import { forwardRef } from 'react'
import { cn, SidebarMenuButton } from 'ui'
import * as React from 'react'
import { cn, SidebarMenuButton as SidebarMenuButtonComponent } from 'ui'
export interface ProjectBranchSelectorTriggerProps {
displayProjectName: string
@@ -9,55 +9,53 @@ export interface ProjectBranchSelectorTriggerProps {
isProductionBranch: boolean
branchDisplayName: string
onGoToOrganization: () => void
onClick?: () => void
}
export const ProjectBranchSelectorTrigger = forwardRef<
HTMLButtonElement,
ProjectBranchSelectorTriggerProps
export const ProjectBranchSelectorTrigger = React.forwardRef<
React.ElementRef<typeof SidebarMenuButtonComponent>,
ProjectBranchSelectorTriggerProps &
Omit<
React.ComponentPropsWithoutRef<typeof SidebarMenuButtonComponent>,
keyof ProjectBranchSelectorTriggerProps
>
>(
(
{
displayProjectName,
selectedOrgInitial,
isBranch,
branchDisplayName,
onClick,
}: ProjectBranchSelectorTriggerProps,
{ displayProjectName, selectedOrgInitial, isBranch, branchDisplayName, ...buttonProps },
ref
) => {
return (
<SidebarMenuButton
<SidebarMenuButtonComponent
ref={ref}
size="lg"
className="group py-1 gap-1.5 w-full flex h-auto text-left data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground touch-manipulation"
onClick={onClick}
ref={ref}
{...buttonProps}
>
<div className="relative flex h-8 aspect-square shrink-0 items-center bg-background-muted group-hover:border-stronger justify-center rounded border border-strong text-xs">
{selectedOrgInitial}
</div>
<div className="text-left flex-grow min-w-0">
<div className="w-full truncate text-foreground leading-tight max-w-[250px]">
{displayProjectName}
</div>
<div
className={cn(
'flex items-center gap-0.5',
isBranch ? 'text-foreground-lighter' : 'text-warning'
)}
>
<GitBranch className="shrink-0 size-3" strokeWidth={1.5} />
<span className="truncate min-w-0 leading-tight text-xs">{branchDisplayName}</span>
<div className="text-left flex-grow min-w-0">
<div className="w-full truncate text-foreground leading-tight max-w-[250px]">
{displayProjectName}
</div>
<div
className={cn(
'flex items-center gap-0.5',
isBranch ? 'text-foreground-lighter' : 'text-warning'
)}
>
<GitBranch className="shrink-0 size-3" strokeWidth={1.5} />
<span className="truncate min-w-0 leading-tight text-xs">{branchDisplayName}</span>
</div>
</div>
</div>
<ChevronsUpDown
strokeWidth={1.5}
className="ml-auto text-foreground-lighter !w-4 !h-4 md:hidden md:group-hover:flex"
className="ml-auto text-foreground-lighter !w-4 !h-4 md:opacity-0 md:group-hover:opacity-100 transition-opacity"
/>
</SidebarMenuButton>
</SidebarMenuButtonComponent>
)
}
)
ProjectBranchSelectorTrigger.displayName = 'ProjectBranchSelectorTrigger'
@@ -1,9 +1,11 @@
import { keepPreviousData } from '@tanstack/react-query'
import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks'
import { OrgProject, useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
import { useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
import type { OrgProject } from 'data/projects/org-projects-infinite-query'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { ChevronsUpDown, HelpCircle } from 'lucide-react'
import { ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import {
Button,
cn,
@@ -52,6 +54,8 @@ interface OrganizationProjectSelectorSelectorProps {
modal?: boolean
/** When true, render only the command list (no popover/trigger). For use inside sheet or popover. */
embedded?: boolean
/** When true, render the desktop (non-embedded) command content without a trigger/popover. */
renderOnlyContent?: boolean
className?: string
}
@@ -72,6 +76,7 @@ export const OrganizationProjectSelector = ({
fetchOnMount = false,
modal = false,
embedded = false,
renderOnlyContent = false,
className,
}: OrganizationProjectSelectorSelectorProps) => {
const { data: organization } = useSelectedOrganizationQuery()
@@ -80,6 +85,7 @@ export const OrganizationProjectSelector = ({
const [openInternal, setOpenInternal] = useState(false)
const open = _open ?? openInternal
const setOpen = _setOpen ?? setOpenInternal
const shouldForceOpen = renderOnlyContent
const listboxId = useId()
const [search, setSearch] = useState('')
@@ -104,7 +110,7 @@ export const OrganizationProjectSelector = ({
fetchNextPage,
} = useOrgProjectsInfiniteQuery(
{ slug, search: search.length === 0 ? search : debouncedSearch },
{ enabled: fetchOnMount || open, placeholderData: keepPreviousData }
{ enabled: fetchOnMount || open || shouldForceOpen, placeholderData: keepPreviousData }
)
const projects = useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || []
@@ -135,8 +141,7 @@ export const OrganizationProjectSelector = ({
if (!isLoadingProjects && isSuccessProjects) {
onInitialLoad?.(projects)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoadingProjects, isSuccessProjects])
}, [isLoadingProjects, isSuccessProjects, onInitialLoad, projects])
function renderListContent() {
if (isLoadingProjects) {
@@ -190,14 +195,21 @@ export const OrganizationProjectSelector = ({
)
}
return (
<ScrollArea className={(projects || []).length > 7 ? 'h-full md:h-[210px]' : ''}>
<ScrollArea
className={
renderOnlyContent ? 'h-full' : (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)}
onClose={() => {
if (renderOnlyContent) return
setOpen(false)
}}
renderRow={renderRow}
checkPosition={checkPosition}
isOptionDisabled={isOptionDisabled}
@@ -216,7 +228,10 @@ export const OrganizationProjectSelector = ({
const commandContent = (
<Command_Shadcn_
shouldFilter={false}
className={cn(className, embedded && 'flex flex-col flex-1 min-h-0 overflow-hidden')}
className={cn(
className,
(embedded || renderOnlyContent) && '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">
@@ -229,26 +244,39 @@ export const OrganizationProjectSelector = ({
onValueChange={setSearch}
placeholder={searchPlaceholder}
handleReset={() => setSearch('')}
wrapperClassName={embedded ? 'shrink-0 border-b' : undefined}
wrapperClassName={embedded || renderOnlyContent ? 'shrink-0 border-b' : undefined}
className="text-base sm:text-sm"
/>
<CommandList_Shadcn_
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_Shadcn_ className={embedded ? 'flex-1 min-h-0 overflow-hidden' : ''}>
{renderListContent()}
</CommandGroup_Shadcn_>
{!!renderActions && !embedded && (
<>
<div className="h-px bg-border-overlay -mx-1 shrink-0" />
{renderActions(setOpen)}
</>
)}
</CommandList_Shadcn_>
{renderOnlyContent ? (
<>
<div className="flex-1 min-h-0 w-full">
<CommandList_Shadcn_ className="h-full max-h-none w-full overflow-hidden overflow-x-hidden">
<CommandGroup_Shadcn_ className="min-h-0 overflow-hidden">
{renderListContent()}
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</div>
{!!renderActions && <div className="shrink-0 border-t">{renderActions(setOpen)}</div>}
</>
) : (
<CommandList_Shadcn_
className={
embedded
? 'flex-1 min-h-0 overflow-y-auto overflow-x-hidden !max-h-none'
: 'max-h-none md:max-h-[300px] h-full flex-1 overflow-y-auto overflow-x-hidden'
}
>
<CommandGroup_Shadcn_ className={embedded ? 'flex-1 min-h-0 overflow-hidden' : ''}>
{renderListContent()}
</CommandGroup_Shadcn_>
{!!renderActions && !embedded && (
<>
<div className="h-px bg-border-overlay -mx-1 shrink-0" />
{renderActions(setOpen)}
</>
)}
</CommandList_Shadcn_>
)}
</Command_Shadcn_>
)
@@ -256,6 +284,10 @@ export const OrganizationProjectSelector = ({
return commandContent
}
if (renderOnlyContent) {
return commandContent
}
return (
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={modal}>
<PopoverTrigger_Shadcn_ asChild>
@@ -270,7 +302,6 @@ export const OrganizationProjectSelector = ({
<Button
block
type="default"
role="combobox"
size="small"
aria-expanded={open}
aria-controls={listboxId}