Files
supabase/apps/studio/components/ui/OrganizationProjectSelector.tsx
Gildas Garcia 243e079a2c chore: remove _Shadcn_ suffix from Command components (#46153)
## Problem

The `_Shadcn_` suffix isn't needed anymore on `Command` components

## Solution

- Remove the `_Shadcn_` suffix
- Simplify UI package exports
- Apply prettier

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

## Summary by CodeRabbit

* **Refactor**
* Simplified command component imports and exports across the UI library
by removing internal naming aliases and adopting direct component
references. Updated the public UI package barrel export to use wildcard
re-exports for cleaner API surface.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46153?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-20 15:45:32 +02:00

303 lines
8.6 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
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,
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)}
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)}
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
type="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>
)
}