From 8cd061f07620cee4ea7eada1af6ac199dd29cd21 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Tue, 31 Mar 2026 17:24:02 +0200 Subject: [PATCH] unified org project branch selector --- .../layouts/AppLayout/BranchDropdown.tsx | 1 + .../layouts/AppLayout/OrgCommandItem.tsx | 49 ++- .../OrganizationDropdownCommandContent.tsx | 41 ++- .../Navigation/LayoutHeader/LayoutHeader.tsx | 17 +- .../Navigation/NavigationBar/OrgSelector.tsx | 2 +- .../ProjectBranchSelectorPopover.tsx | 341 +++++++++++++++++- .../ProjectBranchSelectorTrigger.tsx | 58 ++- .../ui/OrganizationProjectSelector.tsx | 85 +++-- 8 files changed, 500 insertions(+), 94 deletions(-) diff --git a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx index 1449d9e9b11..1ac0cb855c9 100644 --- a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -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 = { diff --git a/apps/studio/components/layouts/AppLayout/OrgCommandItem.tsx b/apps/studio/components/layouts/AppLayout/OrgCommandItem.tsx index fed9165495b..732b096f307 100644 --- a/apps/studio/components/layouts/AppLayout/OrgCommandItem.tsx +++ b/apps/studio/components/layouts/AppLayout/OrgCommandItem.tsx @@ -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() + }} > - -
- {org.name} - + {onSelectOrganization ? ( +
+
+ {org.name} + +
+ {org.slug === selectedSlug && }
- {org.slug === selectedSlug && } - + ) : ( + +
+ {org.name} + +
+ {org.slug === selectedSlug && } + + )} ) } diff --git a/apps/studio/components/layouts/AppLayout/OrganizationDropdownCommandContent.tsx b/apps/studio/components/layouts/AppLayout/OrganizationDropdownCommandContent.tsx index 5644eaded72..41c372fc616 100644 --- a/apps/studio/components/layouts/AppLayout/OrganizationDropdownCommandContent.tsx +++ b/apps/studio/components/layouts/AppLayout/OrganizationDropdownCommandContent.tsx @@ -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 ( - +
+ + )} + onClose()}> + + + +
) } diff --git a/apps/studio/components/layouts/Navigation/NavigationBar/ProjectBranchSelectorTrigger.tsx b/apps/studio/components/layouts/Navigation/NavigationBar/ProjectBranchSelectorTrigger.tsx index bfaab0bb2b5..7757c39bd93 100644 --- a/apps/studio/components/layouts/Navigation/NavigationBar/ProjectBranchSelectorTrigger.tsx +++ b/apps/studio/components/layouts/Navigation/NavigationBar/ProjectBranchSelectorTrigger.tsx @@ -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, + ProjectBranchSelectorTriggerProps & + Omit< + React.ComponentPropsWithoutRef, + keyof ProjectBranchSelectorTriggerProps + > >( ( - { - displayProjectName, - selectedOrgInitial, - isBranch, - branchDisplayName, - onClick, - }: ProjectBranchSelectorTriggerProps, + { displayProjectName, selectedOrgInitial, isBranch, branchDisplayName, ...buttonProps }, ref ) => { return ( -
{selectedOrgInitial}
-
- {displayProjectName} -
-
- - {branchDisplayName} +
+
+ {displayProjectName} +
+
+ + {branchDisplayName} +
- + ) } ) - ProjectBranchSelectorTrigger.displayName = 'ProjectBranchSelectorTrigger' diff --git a/apps/studio/components/ui/OrganizationProjectSelector.tsx b/apps/studio/components/ui/OrganizationProjectSelector.tsx index 10e55d027ca..7579ae73eee 100644 --- a/apps/studio/components/ui/OrganizationProjectSelector.tsx +++ b/apps/studio/components/ui/OrganizationProjectSelector.tsx @@ -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 ( - 7 ? 'h-full md:h-[210px]' : ''}> + 7 ? 'h-full md:h-[210px]' : '' + } + > {projects?.map((project) => ( setOpen(false)} + onClose={() => { + if (renderOnlyContent) return + setOpen(false) + }} renderRow={renderRow} checkPosition={checkPosition} isOptionDisabled={isOptionDisabled} @@ -216,7 +228,10 @@ export const OrganizationProjectSelector = ({ const commandContent = ( {embedded && !!renderActions && (
@@ -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" /> - - - {renderListContent()} - - {!!renderActions && !embedded && ( - <> -
- {renderActions(setOpen)} - - )} - + {renderOnlyContent ? ( + <> +
+ + + {renderListContent()} + + +
+ {!!renderActions &&
{renderActions(setOpen)}
} + + ) : ( + + + {renderListContent()} + + {!!renderActions && !embedded && ( + <> +
+ {renderActions(setOpen)} + + )} + + )} ) @@ -256,6 +284,10 @@ export const OrganizationProjectSelector = ({ return commandContent } + if (renderOnlyContent) { + return commandContent + } + return ( @@ -270,7 +302,6 @@ export const OrganizationProjectSelector = ({