mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 11:55:05 +03: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 -->
351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useDebounce } from '~/hooks/useDebounce'
|
|
import { useIntersectionObserver } from '~/hooks/useIntersectionObserver'
|
|
import { useProjectsInfiniteQuery } from '~/lib/fetch/projects-infinite'
|
|
import { useSendTelemetryEvent } from '~/lib/telemetry'
|
|
import { useIsLoggedIn, useIsUserLoading } from 'common'
|
|
import { Check, ChevronDown } from 'lucide-react'
|
|
import { useTheme } from 'next-themes'
|
|
import Link from 'next/link'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
Button,
|
|
cn,
|
|
Command,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
ScrollArea,
|
|
} from 'ui'
|
|
import { Admonition } from 'ui-patterns'
|
|
import {
|
|
createMcpCopyHandler,
|
|
McpConfigPanel as McpConfigPanelBase,
|
|
type McpClient,
|
|
} from 'ui-patterns/McpUrlBuilder'
|
|
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
|
|
|
type PlatformType = (typeof PLATFORMS)[number]['value']
|
|
|
|
const PLATFORMS = [
|
|
{ value: 'hosted', label: 'Hosted' },
|
|
{ value: 'local', label: 'CLI' },
|
|
] as const satisfies Array<{ value: string; label: string }>
|
|
|
|
// [Joshen] Ideally we consolidate this component with what's in ProjectConfigVariables - they seem to be doing the same thing
|
|
function ProjectSelector({
|
|
className,
|
|
selectedProject,
|
|
onProjectSelect,
|
|
}: {
|
|
className?: string
|
|
selectedProject?: { ref: string; name: string } | null
|
|
onProjectSelect?: (project: { ref: string; name: string } | null) => void
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
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 isUserLoading = useIsUserLoading()
|
|
const isLoggedIn = useIsLoggedIn()
|
|
|
|
const {
|
|
data: projectsData,
|
|
isLoading,
|
|
isError,
|
|
isFetching,
|
|
isFetchingNextPage,
|
|
hasNextPage,
|
|
fetchNextPage,
|
|
} = useProjectsInfiniteQuery(
|
|
{ search: search.length === 0 ? search : debouncedSearch },
|
|
{ enabled: isLoggedIn }
|
|
)
|
|
const projects =
|
|
useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || []
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!isLoading &&
|
|
!isFetching &&
|
|
!isFetchingNextPage &&
|
|
hasNextPage &&
|
|
entry?.isIntersecting &&
|
|
!!fetchNextPage
|
|
) {
|
|
fetchNextPage()
|
|
}
|
|
}, [isLoading, isFetching, isFetchingNextPage, hasNextPage, entry?.isIntersecting, fetchNextPage])
|
|
|
|
return (
|
|
<Popover
|
|
modal={false}
|
|
open={open}
|
|
onOpenChange={(open) => {
|
|
setOpen(open)
|
|
if (!open) setSearch('')
|
|
}}
|
|
>
|
|
<div className={cn('flex', className)}>
|
|
<span className="flex items-center text-foreground-lighter px-3 rounded-lg rounded-r-none text-xs border border-button border-r-0">
|
|
Project
|
|
</span>
|
|
|
|
{!isUserLoading && !isLoggedIn ? (
|
|
<Button size="small" type="default" className="gap-0 rounded-l-none" asChild>
|
|
<Link href="https://supabase.com/dashboard" rel="noreferrer noopener" target="_blank">
|
|
<div className="flex items-center gap-2">Log in to choose a project</div>
|
|
</Link>
|
|
</Button>
|
|
) : (
|
|
<PopoverTrigger asChild disabled={isUserLoading || isLoading || isError}>
|
|
<Button
|
|
size="small"
|
|
type="default"
|
|
className="gap-0 rounded-l-none"
|
|
iconRight={
|
|
<ChevronDown
|
|
strokeWidth={1.5}
|
|
className={cn('transition-transform duration-200', open && 'rotate-180')}
|
|
/>
|
|
}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{selectedProject?.name ??
|
|
(isUserLoading || isLoading
|
|
? 'Loading projects...'
|
|
: isError
|
|
? 'Error fetching projects'
|
|
: 'Select a project')}
|
|
</div>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
)}
|
|
</div>
|
|
<PopoverContent className="mt-0 p-0 w-56" side="bottom" align="start">
|
|
<Command shouldFilter={false}>
|
|
<CommandInput
|
|
placeholder="Search ..."
|
|
className="h-8"
|
|
showResetIcon
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
handleReset={() => setSearch('')}
|
|
/>
|
|
<CommandList>
|
|
<CommandGroup>
|
|
{isLoading ? (
|
|
<div className="px-2 py-1 flex flex-col gap-2">
|
|
<ShimmeringLoader className="w-full" />
|
|
<ShimmeringLoader className="w-4/5" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{search.length > 0 && projects.length === 0 && (
|
|
<p className="text-xs text-center text-foreground-lighter py-3">
|
|
No projects found based on your search
|
|
</p>
|
|
)}
|
|
<ScrollArea className={projects.length > 7 ? 'h-[210px]' : ''}>
|
|
{projects?.map((project) => (
|
|
<CommandItem
|
|
key={project.ref}
|
|
value={project.ref}
|
|
onSelect={() => {
|
|
onProjectSelect?.(project.ref === selectedProject?.ref ? null : project)
|
|
setOpen(false)
|
|
}}
|
|
className="flex gap-2 items-center"
|
|
>
|
|
{project.name}
|
|
<Check
|
|
aria-label={project.ref === selectedProject?.ref ? 'selected' : undefined}
|
|
size={15}
|
|
className={cn(
|
|
'ml-auto',
|
|
project.ref === selectedProject?.ref ? 'opacity-100' : 'opacity-0'
|
|
)}
|
|
/>
|
|
</CommandItem>
|
|
))}
|
|
<div ref={sentinelRef} className="h-1 -mt-1" />
|
|
{hasNextPage && <ShimmeringLoader className="px-2 py-3" />}
|
|
</ScrollArea>
|
|
</>
|
|
)}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
function PlatformSelector({
|
|
className,
|
|
selectedPlatform,
|
|
onPlatformSelect,
|
|
}: {
|
|
className?: string
|
|
selectedPlatform: PlatformType
|
|
onPlatformSelect?: (platform: PlatformType) => void
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
|
<div className={cn('flex', className)}>
|
|
<span className="flex items-center text-foreground-lighter px-3 rounded-lg rounded-r-none text-xs border border-button border-r-0">
|
|
Platform
|
|
</span>
|
|
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
size="small"
|
|
type="default"
|
|
className="gap-0 rounded-l-none"
|
|
iconRight={
|
|
<ChevronDown
|
|
strokeWidth={1.5}
|
|
className={cn('transition-transform duration-200', open && 'rotate-180')}
|
|
/>
|
|
}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{PLATFORMS.find((p) => p.value === selectedPlatform)?.label}
|
|
</div>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
</div>
|
|
<PopoverContent className="mt-0 p-0 max-w-48" side="bottom" align="start">
|
|
<Command>
|
|
<CommandList>
|
|
<CommandGroup>
|
|
{PLATFORMS.map((platform) => (
|
|
<CommandItem
|
|
key={platform.value}
|
|
value={platform.value}
|
|
onSelect={() => {
|
|
onPlatformSelect?.(platform.value)
|
|
setOpen(false)
|
|
}}
|
|
className="flex gap-2 items-center"
|
|
>
|
|
{platform.label}
|
|
<Check
|
|
aria-label={platform.value === selectedPlatform ? 'selected' : undefined}
|
|
size={15}
|
|
className={cn(
|
|
'ml-auto',
|
|
platform.value === selectedPlatform ? 'opacity-100' : 'opacity-0'
|
|
)}
|
|
/>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
export function McpConfigPanel() {
|
|
const [selectedProject, setSelectedProject] = useState<{ ref: string; name: string } | null>(null)
|
|
const [selectedPlatform, setSelectedPlatform] = useState<'hosted' | 'local'>('hosted')
|
|
const [selectedClient, setSelectedClient] = useState<McpClient | null>(null)
|
|
const { resolvedTheme } = useTheme()
|
|
const sendTelemetryEvent = useSendTelemetryEvent()
|
|
|
|
const isPlatform = selectedPlatform === 'hosted'
|
|
const project = isPlatform ? selectedProject : null
|
|
|
|
const handleCopy = useMemo(
|
|
() =>
|
|
createMcpCopyHandler({
|
|
selectedClient,
|
|
source: 'docs',
|
|
onTrack: (event) => {
|
|
sendTelemetryEvent({
|
|
action: event.action,
|
|
properties: event.properties,
|
|
groups: (event.groups || {}) as any,
|
|
})
|
|
},
|
|
projectRef: project?.ref,
|
|
}),
|
|
[selectedClient, sendTelemetryEvent, project?.ref]
|
|
)
|
|
|
|
const handleInstall = () => {
|
|
if (selectedClient?.label) {
|
|
sendTelemetryEvent({
|
|
action: 'mcp_install_button_clicked',
|
|
properties: {
|
|
client: selectedClient.label,
|
|
source: 'docs',
|
|
},
|
|
groups: {
|
|
...(project?.ref && { project: project.ref }),
|
|
} as any,
|
|
})
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="not-prose">
|
|
<div className="flex flex-wrap gap-3 mb-3">
|
|
<PlatformSelector
|
|
selectedPlatform={selectedPlatform}
|
|
onPlatformSelect={setSelectedPlatform}
|
|
/>
|
|
{isPlatform && (
|
|
<ProjectSelector selectedProject={project} onProjectSelect={setSelectedProject} />
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-foreground-lighter">
|
|
{isPlatform
|
|
? 'Scope the MCP server to a project. If no project is selected, all projects will be accessible.'
|
|
: 'Project selection is only available for the hosted platform.'}
|
|
</p>
|
|
<McpConfigPanelBase
|
|
className="mt-6"
|
|
projectRef={project?.ref}
|
|
theme={resolvedTheme as 'light' | 'dark'}
|
|
isPlatform={isPlatform}
|
|
onCopyCallback={handleCopy}
|
|
onInstallCallback={handleInstall}
|
|
onClientSelect={setSelectedClient}
|
|
/>
|
|
</div>
|
|
{isPlatform && (
|
|
<Admonition type="note" title="Authentication" className="mt-3">
|
|
<p>
|
|
{
|
|
"Some MCP clients will automatically prompt you to login during setup, while others may require manual authentication steps. Either authentication method will open a browser window where you can login to your Supabase account and grant organization access to the MCP client. In the future, we'll offer more fine grain control over these permissions."
|
|
}
|
|
</p>
|
|
<p>
|
|
{
|
|
'Previously Supabase MCP required you to generate a personal access token (PAT), but this is no longer required.'
|
|
}
|
|
</p>
|
|
</Admonition>
|
|
)}
|
|
</>
|
|
)
|
|
}
|