mirror of
https://github.com/supabase/supabase.git
synced 2026-06-10 04:26:19 +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 -->
300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
|
import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks'
|
|
import { Check, ChevronsUpDown, CircleAlert, Info } from 'lucide-react'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
Button,
|
|
cn,
|
|
Command,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
ScrollArea,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from 'ui'
|
|
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
|
|
|
import { exposedTableCountsQueryOptions } from '@/data/privileges/exposed-table-counts-query'
|
|
import { exposedTablesInfiniteQueryOptions } from '@/data/privileges/exposed-tables-infinite-query'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { pluralize } from '@/lib/helpers'
|
|
|
|
interface ExposedTableSelectorProps {
|
|
disabled?: boolean
|
|
selectedSchemas: string[]
|
|
pendingAddTableIds: number[]
|
|
pendingRemoveTableIds: number[]
|
|
onTogglePendingAdd: (tableId: number) => void
|
|
onTogglePendingRemove: (tableId: number) => void
|
|
}
|
|
|
|
export const ExposedTableSelector = ({
|
|
disabled = false,
|
|
selectedSchemas,
|
|
pendingAddTableIds,
|
|
pendingRemoveTableIds,
|
|
onTogglePendingAdd,
|
|
onTogglePendingRemove,
|
|
}: ExposedTableSelectorProps) => {
|
|
const [open, setOpen] = useState(false)
|
|
const [search, setSearch] = useState('')
|
|
const debouncedSearch = useDebounce(search, 300)
|
|
|
|
const { data: project } = useSelectedProjectQuery()
|
|
|
|
const scrollRootRef = useRef<HTMLDivElement | null>(null)
|
|
const [sentinelRef, entry] = useIntersectionObserver({
|
|
root: scrollRootRef.current,
|
|
threshold: 0,
|
|
rootMargin: '0px',
|
|
})
|
|
|
|
const { data: countsData, isPending: isCountsPending } = useQuery({
|
|
...exposedTableCountsQueryOptions({
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
selectedSchemas,
|
|
}),
|
|
placeholderData: keepPreviousData,
|
|
})
|
|
const pendingCount = pendingAddTableIds.length + pendingRemoveTableIds.length
|
|
|
|
const totalCount = countsData?.total_count ?? 0
|
|
const grantsCount = countsData?.grants_count ?? 0
|
|
|
|
const { data, isPending, isError, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
|
useInfiniteQuery({
|
|
...exposedTablesInfiniteQueryOptions({
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
search: search.length === 0 ? undefined : debouncedSearch || undefined,
|
|
}),
|
|
placeholderData: search.length > 0 ? keepPreviousData : undefined,
|
|
})
|
|
|
|
const tables = useMemo(() => data?.pages.flatMap((page) => page.tables) ?? [], [data?.pages])
|
|
|
|
const pendingAddSet = useMemo(() => new Set(pendingAddTableIds), [pendingAddTableIds])
|
|
const pendingRemoveSet = useMemo(() => new Set(pendingRemoveTableIds), [pendingRemoveTableIds])
|
|
|
|
useEffect(() => {
|
|
if (!isPending && !isFetching && entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
|
|
fetchNextPage()
|
|
}
|
|
}, [entry?.isIntersecting, hasNextPage, isFetching, isFetchingNextPage, isPending, fetchNextPage])
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
size="small"
|
|
disabled={disabled}
|
|
type="default"
|
|
className="w-full [&>span]:w-full pr-1! space-x-1"
|
|
iconRight={<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />}
|
|
>
|
|
<div className="w-full flex gap-1">
|
|
<p className="text-foreground-lighter">
|
|
{isCountsPending
|
|
? 'Loading tables...'
|
|
: totalCount === 0
|
|
? 'No tables available'
|
|
: `${grantsCount} of ${totalCount} tables exposed${
|
|
pendingCount > 0
|
|
? `, ${pendingCount} pending ${pluralize(pendingCount, 'change')}`
|
|
: ''
|
|
}`}
|
|
</p>
|
|
</div>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0 min-w-[200px] pointer-events-auto"
|
|
side="bottom"
|
|
align="start"
|
|
sameWidthAsTrigger
|
|
>
|
|
<Command shouldFilter={false}>
|
|
<CommandInput
|
|
className="text-xs"
|
|
placeholder="Find table..."
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
/>
|
|
<CommandList>
|
|
<CommandGroup>
|
|
{isPending ? (
|
|
<>
|
|
<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>
|
|
</>
|
|
) : isError ? (
|
|
<div className="flex items-center py-3 justify-center">
|
|
<p className="text-xs text-foreground-lighter">Failed to retrieve tables</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{tables.length === 0 && (
|
|
<p className="text-xs text-center text-foreground-lighter py-3">
|
|
{search.length > 0 ? 'No tables found' : 'No tables available'}
|
|
</p>
|
|
)}
|
|
<ScrollArea ref={scrollRootRef} className={tables.length > 7 ? 'h-[210px]' : ''}>
|
|
{tables.map((table) => {
|
|
const isSchemaExposed = selectedSchemas.includes(table.schema)
|
|
const hasPendingAdd = pendingAddSet.has(table.id)
|
|
const hasPendingRemove = pendingRemoveSet.has(table.id)
|
|
|
|
const isCustomTable = table.status === 'custom'
|
|
const isGranted = table.status === 'granted'
|
|
|
|
const isCustomNeutral = isCustomTable && !hasPendingAdd && !hasPendingRemove
|
|
const isExposed =
|
|
isSchemaExposed &&
|
|
(isCustomTable
|
|
? hasPendingAdd
|
|
: isGranted
|
|
? !hasPendingRemove
|
|
: hasPendingAdd)
|
|
|
|
const customGrantsTooltip = getCustomGrantsTooltip({
|
|
hasPendingAdd,
|
|
hasPendingRemove,
|
|
})
|
|
|
|
return (
|
|
<CommandItem
|
|
key={table.id}
|
|
value={`${table.schema}.${table.name}-${table.id}`}
|
|
className={cn(
|
|
'w-full',
|
|
isSchemaExposed ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed!'
|
|
)}
|
|
onSelect={() => {
|
|
if (!isSchemaExposed) return
|
|
|
|
if (isCustomTable) {
|
|
if (hasPendingAdd) {
|
|
onTogglePendingAdd(table.id)
|
|
onTogglePendingRemove(table.id)
|
|
} else if (hasPendingRemove) {
|
|
onTogglePendingRemove(table.id)
|
|
onTogglePendingAdd(table.id)
|
|
} else {
|
|
onTogglePendingAdd(table.id)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (isGranted) {
|
|
onTogglePendingRemove(table.id)
|
|
} else {
|
|
onTogglePendingAdd(table.id)
|
|
}
|
|
}}
|
|
>
|
|
<div className="w-full flex items-center gap-x-2">
|
|
<div className="w-4 shrink-0 flex items-center justify-center">
|
|
{isExposed && <Check size={16} className="text-brand shrink-0" />}
|
|
{!isSchemaExposed && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
aria-label="Schema not exposed"
|
|
className="inline-flex items-center text-foreground-muted hover:text-foreground-light"
|
|
>
|
|
<Info size={14} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="left" className="max-w-[320px] text-xs">
|
|
The schema "{table.schema}" must be exposed before enabling this
|
|
table.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
'truncate',
|
|
(!isSchemaExposed || isCustomNeutral) && 'text-foreground-muted',
|
|
isCustomNeutral && isSchemaExposed && 'text-warning'
|
|
)}
|
|
>
|
|
{`${table.schema}.${table.name}`}
|
|
</span>
|
|
|
|
<div className="ml-auto flex items-center gap-x-2">
|
|
{isCustomTable && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div
|
|
className={cn(
|
|
'shrink-0 flex items-center justify-center hover:text-foreground-light',
|
|
isCustomNeutral && isSchemaExposed
|
|
? 'text-warning'
|
|
: 'text-foreground-muted'
|
|
)}
|
|
>
|
|
<CircleAlert size={14} />
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side="right"
|
|
className="max-w-[320px] text-xs pointer-events-none"
|
|
>
|
|
{customGrantsTooltip}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CommandItem>
|
|
)
|
|
})}
|
|
<div ref={sentinelRef} className="h-1 -mt-1" />
|
|
{hasNextPage && (
|
|
<div className="px-2 py-1">
|
|
<ShimmeringLoader className="py-2" />
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
</>
|
|
)}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
const getCustomGrantsTooltip = ({
|
|
hasPendingAdd,
|
|
hasPendingRemove,
|
|
}: {
|
|
hasPendingAdd: boolean
|
|
hasPendingRemove: boolean
|
|
}) => {
|
|
if (hasPendingAdd) {
|
|
return 'This table has custom grants. Saving will override them with standard Data API grants for anon, authenticated, and service_role. Select again to revoke all grants instead.'
|
|
}
|
|
|
|
if (hasPendingRemove) {
|
|
return 'This table has custom grants. Saving will revoke all grants for anon, authenticated, and service_role. Select again to override with standard Data API grants instead.'
|
|
}
|
|
|
|
return 'This table has custom grants. Select it to override with standard Data API grants for anon, authenticated, and service_role.'
|
|
}
|