TableList.tsx 24 KB

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