Files
supabase/apps/studio/components/ui/OrganizationProjectSelector.tsx
Danny White 476d4a5851 refactor(ui): drop redundant Button variant="default" props (#50161)
## What kind of change does this PR introduce?

Mechanical cleanup on top of the Button default-variant change (#50160).

## What is the current behavior?

Many callsites still pass `variant="default"` even though that is now
the component default.

## What is the new behavior?

Removes redundant static `variant="default"` from legacy `Button` and
`ButtonTooltip` callsites. Keeps explicit defaults where they document
the API:

- `button-default.tsx` and `button-sizes.tsx` demos
- `DocsButton`, which pins neutral styling at the wrapper boundary

## To test

Studio:

- [Auth → Rate
Limits](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/auth/rate-limits):
dirty the form so Cancel appears; Cancel stays neutral, Save stays green
- [Project Settings → API
Keys](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/settings/api-keys):
`DocsButton` in the header actions stays neutral

Design system:

- [Design system →
Button](https://design-system-git-dnywh-dc924ac1-supabase.vercel.app/design-system/docs/components/button):
`button-default` / `button-sizes` still show explicit default styling;
Primary (green) is restricted to the Primary section (and `asChild`)

WWW:

- [www → Brand
assets](https://zone-www-dot-com-git-dnywh-dc924ac1-supabase.vercel.app/brand-assets):
Download logo kit / Download button kit stay neutral
2026-09-11 17:05:26 +10:00

306 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
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>
)
}