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