| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- 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.'
- }
|