mirror of
https://github.com/supabase/supabase.git
synced 2026-06-12 17:27:58 +08:00
## 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 --> [](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 -->
303 lines
8.6 KiB
TypeScript
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>
|
|
)
|
|
}
|