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