TableList.tsx599 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useParams } from 'common'
3import { noop } from 'lodash'
4import { Check, Copy, Edit, Eye, Filter, MoreVertical, Plus, Search, Trash, X } from 'lucide-react'
5import Link from 'next/link'
6import { useRouter } from 'next/router'
7import { parseAsString, useQueryState } from 'nuqs'
8import { useRef, useState } from 'react'
9import {
10 Button,
11 Card,
12 Checkbox,
13 DropdownMenu,
14 DropdownMenuContent,
15 DropdownMenuItem,
16 DropdownMenuSeparator,
17 DropdownMenuTrigger,
18 Label,
19 Popover,
20 PopoverContent,
21 PopoverTrigger,
22 Table,
23 TableBody,
24 TableCell,
25 TableFooter,
26 TableHead,
27 TableHeader,
28 TableRow,
29 Tooltip,
30 TooltipContent,
31 TooltipTrigger,
32} from 'ui'
33import { Input } from 'ui-patterns/DataInputs/Input'
34import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
35
36import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning'
37import { formatAllEntities } from './Tables.utils'
38import { buildTableEditorUrl } from '@/components/grid/BrivenGrid.utils'
39import AlertError from '@/components/ui/AlertError'
40import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
41import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
42import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon'
43import SchemaSelector from '@/components/ui/SchemaSelector'
44import { Shortcut } from '@/components/ui/Shortcut'
45import { useDatabasePublicationsQuery } from '@/data/database-publications/database-publications-query'
46import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
47import { useForeignTablesQuery } from '@/data/foreign-tables/foreign-tables-query'
48import { useMaterializedViewsQuery } from '@/data/materialized-views/materialized-views-query'
49import { usePrefetchEditorTablePage } from '@/data/prefetchers/project.$ref.editor.$id'
50import { useTablesQuery } from '@/data/tables/tables-query'
51import { useViewsQuery } from '@/data/views/views-query'
52import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
53import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
54import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
55import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
56import { onSearchInputEscape } from '@/lib/keyboard'
57import type { SafePostgresTable } from '@/lib/postgres-types'
58import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
59import { useShortcut } from '@/state/shortcuts/useShortcut'
60
61interface TableListProps {
62 onAddTable: () => void
63 onEditTable: (table: SafePostgresTable) => void
64 onDeleteTable: (table: SafePostgresTable) => void
65 onDuplicateTable: (table: SafePostgresTable) => void
66}
67
68export const TableList = ({
69 onDuplicateTable,
70 onAddTable = noop,
71 onEditTable = noop,
72 onDeleteTable = noop,
73}: TableListProps) => {
74 const router = useRouter()
75 const { ref } = useParams()
76 const { data: project } = useSelectedProjectQuery()
77
78 const prefetchEditorTablePage = usePrefetchEditorTablePage()
79
80 const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
81
82 const [filterString, setFilterString] = useQueryState('search', parseAsString.withDefault(''))
83 const [visibleTypes, setVisibleTypes] = useState<string[]>(Object.values(ENTITY_TYPE))
84 const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
85 const searchInputRef = useRef<HTMLInputElement>(null)
86
87 const { can: canUpdateTables } = useAsyncCheckPermissions(
88 PermissionAction.TENANT_SQL_ADMIN_WRITE,
89 'tables'
90 )
91
92 const {
93 data: tables,
94 error: tablesError,
95 isError: isErrorTables,
96 isPending: isLoadingTables,
97 isSuccess: isSuccessTables,
98 } = useTablesQuery(
99 {
100 projectRef: project?.ref,
101 connectionString: project?.connectionString,
102 schema: selectedSchema,
103 sortByProperty: 'name',
104 includeColumns: true,
105 },
106 {
107 select(tables) {
108 return filterString.length === 0
109 ? tables
110 : tables.filter((table) => table.name.toLowerCase().includes(filterString.toLowerCase()))
111 },
112 }
113 )
114
115 const {
116 data: views,
117 error: viewsError,
118 isError: isErrorViews,
119 isPending: isLoadingViews,
120 isSuccess: isSuccessViews,
121 } = useViewsQuery(
122 {
123 projectRef: project?.ref,
124 connectionString: project?.connectionString,
125 schema: selectedSchema,
126 },
127 {
128 select(views) {
129 return filterString.length === 0
130 ? views
131 : views.filter((view) => view.name.toLowerCase().includes(filterString.toLowerCase()))
132 },
133 }
134 )
135
136 const {
137 data: materializedViews,
138 error: materializedViewsError,
139 isError: isErrorMaterializedViews,
140 isPending: isLoadingMaterializedViews,
141 isSuccess: isSuccessMaterializedViews,
142 } = useMaterializedViewsQuery(
143 {
144 projectRef: project?.ref,
145 connectionString: project?.connectionString,
146 schema: selectedSchema,
147 },
148 {
149 select(materializedViews) {
150 return filterString.length === 0
151 ? materializedViews
152 : materializedViews.filter((view) =>
153 view.name.toLowerCase().includes(filterString.toLowerCase())
154 )
155 },
156 }
157 )
158
159 const {
160 data: foreignTables,
161 error: foreignTablesError,
162 isError: isErrorForeignTables,
163 isPending: isLoadingForeignTables,
164 isSuccess: isSuccessForeignTables,
165 } = useForeignTablesQuery(
166 {
167 projectRef: project?.ref,
168 connectionString: project?.connectionString,
169 schema: selectedSchema,
170 },
171 {
172 select(foreignTables) {
173 return filterString.length === 0
174 ? foreignTables
175 : foreignTables.filter((table) =>
176 table.name.toLowerCase().includes(filterString.toLowerCase())
177 )
178 },
179 }
180 )
181
182 const { data: publications } = useDatabasePublicationsQuery({
183 projectRef: project?.ref,
184 connectionString: project?.connectionString,
185 })
186 const realtimePublication = (publications ?? []).find(
187 (publication) => publication.name === 'briven_realtime'
188 )
189
190 const entities = formatAllEntities({ tables, views, materializedViews, foreignTables }).filter(
191 (x) => visibleTypes.includes(x.type)
192 )
193
194 const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
195
196 const canAddTables = canUpdateTables && !isSchemaLocked
197
198 useShortcut(
199 SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
200 () => {
201 searchInputRef.current?.focus()
202 searchInputRef.current?.select()
203 },
204 { label: 'Search tables' }
205 )
206
207 useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
208 setVisibleTypes(Object.values(ENTITY_TYPE))
209 setFilterString('')
210 })
211
212 const error = tablesError || viewsError || materializedViewsError || foreignTablesError
213 const isError = isErrorTables || isErrorViews || isErrorMaterializedViews || isErrorForeignTables
214 const isLoading =
215 isLoadingTables || isLoadingViews || isLoadingMaterializedViews || isLoadingForeignTables
216 const isSuccess =
217 isSuccessTables && isSuccessViews && isSuccessMaterializedViews && isSuccessForeignTables
218
219 const formatTooltipText = (entityType: string) => {
220 const text =
221 Object.entries(ENTITY_TYPE)
222 .find(([, value]) => value === entityType)?.[0]
223 ?.toLowerCase()
224 ?.split('_')
225 ?.join(' ') || ''
226 // Return sentence case (capitalize first letter only)
227 return text.charAt(0).toUpperCase() + text.slice(1)
228 }
229
230 return (
231 <div className="flex flex-col gap-y-4">
232 <div className="flex flex-col lg:flex-row lg:items-center gap-2 flex-wrap">
233 <div className="flex gap-2 items-center">
234 <Shortcut
235 id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
236 onTrigger={() => setSchemaSelectorOpen(true)}
237 side="bottom"
238 tooltipOpen={schemaSelectorOpen ? false : undefined}
239 >
240 <SchemaSelector
241 className="grow lg:grow-0 w-[180px]"
242 size="tiny"
243 showError={false}
244 selectedSchemaName={selectedSchema}
245 onSelectSchema={setSelectedSchema}
246 open={schemaSelectorOpen}
247 onOpenChange={setSchemaSelectorOpen}
248 />
249 </Shortcut>
250 <Popover>
251 <PopoverTrigger asChild>
252 <Button
253 size="tiny"
254 type={visibleTypes.length !== 5 ? 'default' : 'dashed'}
255 className="px-1"
256 icon={<Filter />}
257 />
258 </PopoverTrigger>
259 <PopoverContent className="p-0 w-56" side="bottom" align="center">
260 <div className="px-3 pt-3 pb-2 flex flex-col gap-y-2">
261 <p className="text-xs">Show entity types</p>
262 <div className="flex flex-col">
263 {Object.entries(ENTITY_TYPE).map(([key, value]) => (
264 <div key={key} className="group flex items-center justify-between py-0.5">
265 <div className="flex items-center gap-x-2">
266 <Checkbox
267 id={key}
268 name={key}
269 checked={visibleTypes.includes(value)}
270 onCheckedChange={() => {
271 if (visibleTypes.includes(value)) {
272 setVisibleTypes(visibleTypes.filter((y) => y !== value))
273 } else {
274 setVisibleTypes(visibleTypes.concat([value]))
275 }
276 }}
277 />
278 <Label htmlFor={key} className="capitalize text-xs">
279 {key.toLowerCase().replace('_', ' ')}
280 </Label>
281 </div>
282 <Button
283 size="tiny"
284 type="default"
285 onClick={() => setVisibleTypes([value])}
286 className="transition opacity-0 group-hover:opacity-100 h-auto px-1 py-0.5"
287 >
288 Select only
289 </Button>
290 </div>
291 ))}
292 </div>
293 </div>
294 </PopoverContent>
295 </Popover>
296 </div>
297 <div className="flex grow justify-between gap-2 items-center">
298 <Input
299 ref={searchInputRef}
300 size="tiny"
301 containerClassName="grow lg:grow-0 w-52"
302 placeholder="Search for a table"
303 value={filterString}
304 onChange={(e) => setFilterString(e.target.value)}
305 onKeyDown={onSearchInputEscape(filterString, setFilterString)}
306 icon={<Search />}
307 />
308
309 {!isSchemaLocked &&
310 (canAddTables ? (
311 <Shortcut
312 id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
313 label="Create new table"
314 onTrigger={() => onAddTable()}
315 side="bottom"
316 >
317 <Button className="w-auto ml-auto" icon={<Plus />} onClick={() => onAddTable()}>
318 New table
319 </Button>
320 </Shortcut>
321 ) : (
322 <ButtonTooltip
323 className="w-auto ml-auto"
324 icon={<Plus />}
325 disabled
326 tooltip={{
327 content: {
328 side: 'bottom',
329 text: 'You need additional permissions to create tables',
330 },
331 }}
332 >
333 New table
334 </ButtonTooltip>
335 ))}
336 </div>
337 </div>
338
339 {isSchemaLocked && <ProtectedSchemaWarning schema={selectedSchema} entity="tables" />}
340
341 {isLoading && <GenericSkeletonLoader />}
342
343 {isError && <AlertError error={error} subject="Failed to retrieve tables" />}
344
345 {isSuccess && (
346 <div className="w-full">
347 <Card>
348 <Table>
349 <TableHeader>
350 <TableRow>
351 <TableHead key="icon" className="w-0 px-0!" />
352 <TableHead key="name" className="max-w-[160px] sm:max-w-[280px]">
353 Name
354 </TableHead>
355 <TableHead key="columns">Columns</TableHead>
356 <TableHead key="rows">Rows (Estimated)</TableHead>
357 <TableHead key="size">Size (Estimated)</TableHead>
358 <TableHead key="realtime">Realtime</TableHead>
359 <TableHead key="buttons"></TableHead>
360 </TableRow>
361 </TableHeader>
362 <TableBody>
363 <>
364 {entities.length === 0 && filterString.length === 0 && (
365 <TableRow key={selectedSchema}>
366 <TableCell colSpan={7}>
367 {visibleTypes.length === 0 ? (
368 <>
369 <p className="text-sm text-foreground">
370 Please select at least one entity type to filter with
371 </p>
372 <p className="text-sm text-foreground-light">
373 There are currently no results based on the filter that you have
374 applied
375 </p>
376 </>
377 ) : (
378 <>
379 <p className="text-sm text-foreground">No tables created yet</p>
380 <p className="text-sm text-foreground-light">
381 There are no{' '}
382 {visibleTypes.length === 5
383 ? 'tables'
384 : visibleTypes.length === 1
385 ? `${formatTooltipText(visibleTypes[0])}s`
386 : `${visibleTypes
387 .slice(0, -1)
388 .map((x) => `${formatTooltipText(x)}s`)
389 .join(
390 ', '
391 )}, and ${formatTooltipText(visibleTypes[visibleTypes.length - 1])}s`}{' '}
392 found in the schema "{selectedSchema}"
393 </p>
394 </>
395 )}
396 </TableCell>
397 </TableRow>
398 )}
399 {entities.length === 0 && filterString.length > 0 && (
400 <TableRow key={selectedSchema}>
401 <TableCell colSpan={7}>
402 <p className="text-sm text-foreground">No results found</p>
403 <p className="text-sm text-foreground-light">
404 Your search for "{filterString}" did not return any results
405 </p>
406 </TableCell>
407 </TableRow>
408 )}
409 {entities.length > 0 &&
410 entities.map((x) => (
411 <TableRow key={x.id}>
412 <TableCell className="w-0 pl-5! pr-1!">
413 <Tooltip>
414 <TooltipTrigger asChild className="cursor-default">
415 <div className="flex w-4 justify-center">
416 <EntityTypeIcon type={x.type} />
417 </div>
418 </TooltipTrigger>
419 <TooltipContent side="bottom">
420 {formatTooltipText(x.type)}
421 </TooltipContent>
422 </Tooltip>
423 </TableCell>
424 <TableCell className="max-w-[160px] sm:max-w-[280px]">
425 <div className="flex min-w-0 flex-col">
426 {/* only show tooltips if required, to reduce noise */}
427 {x.name.length > 20 ? (
428 <Tooltip disableHoverableContent={true}>
429 <TooltipTrigger
430 asChild
431 className="max-w-[95%] overflow-hidden text-ellipsis whitespace-nowrap"
432 >
433 <p>{x.name}</p>
434 </TooltipTrigger>
435
436 <TooltipContent side="bottom">{x.name}</TooltipContent>
437 </Tooltip>
438 ) : (
439 <p>{x.name}</p>
440 )}
441 {x.comment !== null ? (
442 <span
443 className="max-w-md truncate text-foreground-lighter"
444 title={x.comment}
445 >
446 {x.comment}
447 </span>
448 ) : null}
449 </div>
450 </TableCell>
451 <TableCell>
452 <p className="text-foreground-light">
453 {x.columns.length.toLocaleString()}
454 </p>
455 </TableCell>
456 <TableCell>
457 {x.rows !== undefined ? (
458 <p className="text-foreground-light">{x.rows.toLocaleString()}</p>
459 ) : (
460 <p className="text-foreground-muted">–</p>
461 )}
462 </TableCell>
463 <TableCell>
464 {x.size !== undefined ? (
465 <p className="text-foreground-light">{x.size}</p>
466 ) : (
467 <p className="text-foreground-muted">–</p>
468 )}
469 </TableCell>
470 <TableCell>
471 {(realtimePublication?.tables ?? []).find(
472 (table) => table.id === x.id
473 ) ? (
474 <div className="flex items-center gap-x-2">
475 <Check size={16} strokeWidth={2} className="text-brand-link" />
476 <p className="text-foreground-light">Enabled</p>
477 </div>
478 ) : (
479 <div className="flex items-center gap-x-2">
480 <X size={16} strokeWidth={2} className="text-foreground-muted" />
481 <p className="text-foreground-lighter">Disabled</p>
482 </div>
483 )}
484 </TableCell>
485 <TableCell>
486 <div className="flex justify-end gap-2">
487 <Button asChild type="default">
488 <Link href={`/project/${ref}/database/tables/${x.id}`}>
489 View columns
490 </Link>
491 </Button>
492
493 {!isSchemaLocked && (
494 <DropdownMenu>
495 <DropdownMenuTrigger asChild>
496 <Button type="default" className="px-1" icon={<MoreVertical />} />
497 </DropdownMenuTrigger>
498 <DropdownMenuContent side="bottom" align="end" className="w-40">
499 <DropdownMenuItem
500 className="flex items-center space-x-2"
501 onClick={() =>
502 router.push(
503 buildTableEditorUrl({
504 projectRef: project?.ref,
505 tableId: x.id,
506 schema: x.schema,
507 })
508 )
509 }
510 onMouseEnter={() =>
511 prefetchEditorTablePage({
512 id: x.id ? String(x.id) : undefined,
513 })
514 }
515 >
516 <Eye size={12} />
517 <p>View in Table Editor</p>
518 </DropdownMenuItem>
519
520 {x.type === ENTITY_TYPE.TABLE && (
521 <>
522 <DropdownMenuSeparator />
523 <DropdownMenuItemTooltip
524 className="gap-x-2"
525 disabled={!canUpdateTables}
526 onClick={() => {
527 if (canUpdateTables) onEditTable(x)
528 }}
529 tooltip={{
530 content: {
531 side: 'left',
532 text: 'You need additional permissions to edit this table',
533 },
534 }}
535 >
536 <Edit size={12} />
537 <p>Edit table</p>
538 </DropdownMenuItemTooltip>
539 <DropdownMenuItemTooltip
540 key="duplicate-table"
541 className="gap-x-2"
542 disabled={!canUpdateTables}
543 onClick={() => {
544 if (canUpdateTables) onDuplicateTable(x)
545 }}
546 tooltip={{
547 content: {
548 side: 'left',
549 text: 'You need additional permissions to duplicate tables',
550 },
551 }}
552 >
553 <Copy size={12} />
554 <span>Duplicate Table</span>
555 </DropdownMenuItemTooltip>
556 <DropdownMenuSeparator />
557 <DropdownMenuItemTooltip
558 disabled={!canUpdateTables || isSchemaLocked}
559 className="gap-x-2"
560 onClick={() => {
561 if (canUpdateTables && !isSchemaLocked) {
562 onDeleteTable({ ...x, schema: selectedSchema })
563 }
564 }}
565 tooltip={{
566 content: {
567 side: 'left',
568 text: 'You need additional permissions to delete tables',
569 },
570 }}
571 >
572 <Trash size={12} />
573 <p>Delete table</p>
574 </DropdownMenuItemTooltip>
575 </>
576 )}
577 </DropdownMenuContent>
578 </DropdownMenu>
579 )}
580 </div>
581 </TableCell>
582 </TableRow>
583 ))}
584 </>
585 </TableBody>
586 <TableFooter className="font-normal">
587 <TableRow className="border-b-0 [&>td]:hover:bg-inherit">
588 <TableCell colSpan={7} className="text-foreground-muted">
589 {entities.length} {entities.length === 1 ? 'table' : 'tables'}
590 </TableCell>
591 </TableRow>
592 </TableFooter>
593 </Table>
594 </Card>
595 </div>
596 )}
597 </div>
598 )
599}