ExposedTableSelector.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
  2. import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks'
  3. import { Check, ChevronsUpDown, CircleAlert, Info } from 'lucide-react'
  4. import { useEffect, useMemo, useRef, useState } from 'react'
  5. import {
  6. Button,
  7. cn,
  8. Command,
  9. CommandGroup,
  10. CommandInput,
  11. CommandItem,
  12. CommandList,
  13. Popover,
  14. PopoverContent,
  15. PopoverTrigger,
  16. ScrollArea,
  17. Tooltip,
  18. TooltipContent,
  19. TooltipTrigger,
  20. } from 'ui'
  21. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  22. import { exposedTableCountsQueryOptions } from '@/data/privileges/exposed-table-counts-query'
  23. import { exposedTablesInfiniteQueryOptions } from '@/data/privileges/exposed-tables-infinite-query'
  24. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  25. import { pluralize } from '@/lib/helpers'
  26. interface ExposedTableSelectorProps {
  27. disabled?: boolean
  28. selectedSchemas: string[]
  29. pendingAddTableIds: number[]
  30. pendingRemoveTableIds: number[]
  31. onTogglePendingAdd: (tableId: number) => void
  32. onTogglePendingRemove: (tableId: number) => void
  33. }
  34. export const ExposedTableSelector = ({
  35. disabled = false,
  36. selectedSchemas,
  37. pendingAddTableIds,
  38. pendingRemoveTableIds,
  39. onTogglePendingAdd,
  40. onTogglePendingRemove,
  41. }: ExposedTableSelectorProps) => {
  42. const [open, setOpen] = useState(false)
  43. const [search, setSearch] = useState('')
  44. const debouncedSearch = useDebounce(search, 300)
  45. const { data: project } = useSelectedProjectQuery()
  46. const scrollRootRef = useRef<HTMLDivElement | null>(null)
  47. const [sentinelRef, entry] = useIntersectionObserver({
  48. root: scrollRootRef.current,
  49. threshold: 0,
  50. rootMargin: '0px',
  51. })
  52. const { data: countsData, isPending: isCountsPending } = useQuery({
  53. ...exposedTableCountsQueryOptions({
  54. projectRef: project?.ref,
  55. connectionString: project?.connectionString,
  56. selectedSchemas,
  57. }),
  58. placeholderData: keepPreviousData,
  59. })
  60. const pendingCount = pendingAddTableIds.length + pendingRemoveTableIds.length
  61. const totalCount = countsData?.total_count ?? 0
  62. const grantsCount = countsData?.grants_count ?? 0
  63. const { data, isPending, isError, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage } =
  64. useInfiniteQuery({
  65. ...exposedTablesInfiniteQueryOptions({
  66. projectRef: project?.ref,
  67. connectionString: project?.connectionString,
  68. search: search.length === 0 ? undefined : debouncedSearch || undefined,
  69. }),
  70. placeholderData: search.length > 0 ? keepPreviousData : undefined,
  71. })
  72. const tables = useMemo(() => data?.pages.flatMap((page) => page.tables) ?? [], [data?.pages])
  73. const pendingAddSet = useMemo(() => new Set(pendingAddTableIds), [pendingAddTableIds])
  74. const pendingRemoveSet = useMemo(() => new Set(pendingRemoveTableIds), [pendingRemoveTableIds])
  75. useEffect(() => {
  76. if (!isPending && !isFetching && entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
  77. fetchNextPage()
  78. }
  79. }, [entry?.isIntersecting, hasNextPage, isFetching, isFetchingNextPage, isPending, fetchNextPage])
  80. return (
  81. <Popover open={open} onOpenChange={setOpen} modal={false}>
  82. <PopoverTrigger asChild>
  83. <Button
  84. size="small"
  85. disabled={disabled}
  86. type="default"
  87. className="w-full [&>span]:w-full pr-1! space-x-1"
  88. iconRight={<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />}
  89. >
  90. <div className="w-full flex gap-1">
  91. <p className="text-foreground-lighter">
  92. {isCountsPending
  93. ? 'Loading tables...'
  94. : totalCount === 0
  95. ? 'No tables available'
  96. : `${grantsCount} of ${totalCount} tables exposed${
  97. pendingCount > 0
  98. ? `, ${pendingCount} pending ${pluralize(pendingCount, 'change')}`
  99. : ''
  100. }`}
  101. </p>
  102. </div>
  103. </Button>
  104. </PopoverTrigger>
  105. <PopoverContent
  106. className="p-0 min-w-[200px] pointer-events-auto"
  107. side="bottom"
  108. align="start"
  109. sameWidthAsTrigger
  110. >
  111. <Command shouldFilter={false}>
  112. <CommandInput
  113. className="text-xs"
  114. placeholder="Find table..."
  115. value={search}
  116. onValueChange={setSearch}
  117. />
  118. <CommandList>
  119. <CommandGroup>
  120. {isPending ? (
  121. <>
  122. <div className="px-2 py-1">
  123. <ShimmeringLoader className="py-2" />
  124. </div>
  125. <div className="px-2 py-1 w-4/5">
  126. <ShimmeringLoader className="py-2" />
  127. </div>
  128. </>
  129. ) : isError ? (
  130. <div className="flex items-center py-3 justify-center">
  131. <p className="text-xs text-foreground-lighter">Failed to retrieve tables</p>
  132. </div>
  133. ) : (
  134. <>
  135. {tables.length === 0 && (
  136. <p className="text-xs text-center text-foreground-lighter py-3">
  137. {search.length > 0 ? 'No tables found' : 'No tables available'}
  138. </p>
  139. )}
  140. <ScrollArea ref={scrollRootRef} className={tables.length > 7 ? 'h-[210px]' : ''}>
  141. {tables.map((table) => {
  142. const isSchemaExposed = selectedSchemas.includes(table.schema)
  143. const hasPendingAdd = pendingAddSet.has(table.id)
  144. const hasPendingRemove = pendingRemoveSet.has(table.id)
  145. const isCustomTable = table.status === 'custom'
  146. const isGranted = table.status === 'granted'
  147. const isCustomNeutral = isCustomTable && !hasPendingAdd && !hasPendingRemove
  148. const isExposed =
  149. isSchemaExposed &&
  150. (isCustomTable
  151. ? hasPendingAdd
  152. : isGranted
  153. ? !hasPendingRemove
  154. : hasPendingAdd)
  155. const customGrantsTooltip = getCustomGrantsTooltip({
  156. hasPendingAdd,
  157. hasPendingRemove,
  158. })
  159. return (
  160. <CommandItem
  161. key={table.id}
  162. value={`${table.schema}.${table.name}-${table.id}`}
  163. className={cn(
  164. 'w-full',
  165. isSchemaExposed ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed!'
  166. )}
  167. onSelect={() => {
  168. if (!isSchemaExposed) return
  169. if (isCustomTable) {
  170. if (hasPendingAdd) {
  171. onTogglePendingAdd(table.id)
  172. onTogglePendingRemove(table.id)
  173. } else if (hasPendingRemove) {
  174. onTogglePendingRemove(table.id)
  175. onTogglePendingAdd(table.id)
  176. } else {
  177. onTogglePendingAdd(table.id)
  178. }
  179. return
  180. }
  181. if (isGranted) {
  182. onTogglePendingRemove(table.id)
  183. } else {
  184. onTogglePendingAdd(table.id)
  185. }
  186. }}
  187. >
  188. <div className="w-full flex items-center gap-x-2">
  189. <div className="w-4 shrink-0 flex items-center justify-center">
  190. {isExposed && <Check size={16} className="text-brand shrink-0" />}
  191. {!isSchemaExposed && (
  192. <Tooltip>
  193. <TooltipTrigger asChild>
  194. <button
  195. type="button"
  196. tabIndex={-1}
  197. aria-label="Schema not exposed"
  198. className="inline-flex items-center text-foreground-muted hover:text-foreground-light"
  199. >
  200. <Info size={14} />
  201. </button>
  202. </TooltipTrigger>
  203. <TooltipContent side="left" className="max-w-[320px] text-xs">
  204. The schema "{table.schema}" must be exposed before enabling this
  205. table.
  206. </TooltipContent>
  207. </Tooltip>
  208. )}
  209. </div>
  210. <span
  211. className={cn(
  212. 'truncate',
  213. (!isSchemaExposed || isCustomNeutral) && 'text-foreground-muted',
  214. isCustomNeutral && isSchemaExposed && 'text-warning'
  215. )}
  216. >
  217. {`${table.schema}.${table.name}`}
  218. </span>
  219. <div className="ml-auto flex items-center gap-x-2">
  220. {isCustomTable && (
  221. <Tooltip>
  222. <TooltipTrigger asChild>
  223. <div
  224. className={cn(
  225. 'shrink-0 flex items-center justify-center hover:text-foreground-light',
  226. isCustomNeutral && isSchemaExposed
  227. ? 'text-warning'
  228. : 'text-foreground-muted'
  229. )}
  230. >
  231. <CircleAlert size={14} />
  232. </div>
  233. </TooltipTrigger>
  234. <TooltipContent
  235. side="right"
  236. className="max-w-[320px] text-xs pointer-events-none"
  237. >
  238. {customGrantsTooltip}
  239. </TooltipContent>
  240. </Tooltip>
  241. )}
  242. </div>
  243. </div>
  244. </CommandItem>
  245. )
  246. })}
  247. <div ref={sentinelRef} className="h-1 -mt-1" />
  248. {hasNextPage && (
  249. <div className="px-2 py-1">
  250. <ShimmeringLoader className="py-2" />
  251. </div>
  252. )}
  253. </ScrollArea>
  254. </>
  255. )}
  256. </CommandGroup>
  257. </CommandList>
  258. </Command>
  259. </PopoverContent>
  260. </Popover>
  261. )
  262. }
  263. const getCustomGrantsTooltip = ({
  264. hasPendingAdd,
  265. hasPendingRemove,
  266. }: {
  267. hasPendingAdd: boolean
  268. hasPendingRemove: boolean
  269. }) => {
  270. if (hasPendingAdd) {
  271. 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.'
  272. }
  273. if (hasPendingRemove) {
  274. 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.'
  275. }
  276. return 'This table has custom grants. Select it to override with standard Data API grants for anon, authenticated, and service_role.'
  277. }