ExposedFunctionSelector.tsx 12 KB

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