| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599 |
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { useParams } from 'common'
- import { noop } from 'lodash'
- import { Check, Copy, Edit, Eye, Filter, MoreVertical, Plus, Search, Trash, X } from 'lucide-react'
- import Link from 'next/link'
- import { useRouter } from 'next/router'
- import { parseAsString, useQueryState } from 'nuqs'
- import { useRef, useState } from 'react'
- import {
- Button,
- Card,
- Checkbox,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
- Label,
- Popover,
- PopoverContent,
- PopoverTrigger,
- Table,
- TableBody,
- TableCell,
- TableFooter,
- TableHead,
- TableHeader,
- TableRow,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- } from 'ui'
- import { Input } from 'ui-patterns/DataInputs/Input'
- import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
- import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning'
- import { formatAllEntities } from './Tables.utils'
- import { buildTableEditorUrl } from '@/components/grid/BrivenGrid.utils'
- import AlertError from '@/components/ui/AlertError'
- import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
- import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
- import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon'
- import SchemaSelector from '@/components/ui/SchemaSelector'
- import { Shortcut } from '@/components/ui/Shortcut'
- import { useDatabasePublicationsQuery } from '@/data/database-publications/database-publications-query'
- import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
- import { useForeignTablesQuery } from '@/data/foreign-tables/foreign-tables-query'
- import { useMaterializedViewsQuery } from '@/data/materialized-views/materialized-views-query'
- import { usePrefetchEditorTablePage } from '@/data/prefetchers/project.$ref.editor.$id'
- import { useTablesQuery } from '@/data/tables/tables-query'
- import { useViewsQuery } from '@/data/views/views-query'
- import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
- import { onSearchInputEscape } from '@/lib/keyboard'
- import type { SafePostgresTable } from '@/lib/postgres-types'
- import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
- import { useShortcut } from '@/state/shortcuts/useShortcut'
- interface TableListProps {
- onAddTable: () => void
- onEditTable: (table: SafePostgresTable) => void
- onDeleteTable: (table: SafePostgresTable) => void
- onDuplicateTable: (table: SafePostgresTable) => void
- }
- export const TableList = ({
- onDuplicateTable,
- onAddTable = noop,
- onEditTable = noop,
- onDeleteTable = noop,
- }: TableListProps) => {
- const router = useRouter()
- const { ref } = useParams()
- const { data: project } = useSelectedProjectQuery()
- const prefetchEditorTablePage = usePrefetchEditorTablePage()
- const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
- const [filterString, setFilterString] = useQueryState('search', parseAsString.withDefault(''))
- const [visibleTypes, setVisibleTypes] = useState<string[]>(Object.values(ENTITY_TYPE))
- const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
- const searchInputRef = useRef<HTMLInputElement>(null)
- const { can: canUpdateTables } = useAsyncCheckPermissions(
- PermissionAction.TENANT_SQL_ADMIN_WRITE,
- 'tables'
- )
- const {
- data: tables,
- error: tablesError,
- isError: isErrorTables,
- isPending: isLoadingTables,
- isSuccess: isSuccessTables,
- } = useTablesQuery(
- {
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- schema: selectedSchema,
- sortByProperty: 'name',
- includeColumns: true,
- },
- {
- select(tables) {
- return filterString.length === 0
- ? tables
- : tables.filter((table) => table.name.toLowerCase().includes(filterString.toLowerCase()))
- },
- }
- )
- const {
- data: views,
- error: viewsError,
- isError: isErrorViews,
- isPending: isLoadingViews,
- isSuccess: isSuccessViews,
- } = useViewsQuery(
- {
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- schema: selectedSchema,
- },
- {
- select(views) {
- return filterString.length === 0
- ? views
- : views.filter((view) => view.name.toLowerCase().includes(filterString.toLowerCase()))
- },
- }
- )
- const {
- data: materializedViews,
- error: materializedViewsError,
- isError: isErrorMaterializedViews,
- isPending: isLoadingMaterializedViews,
- isSuccess: isSuccessMaterializedViews,
- } = useMaterializedViewsQuery(
- {
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- schema: selectedSchema,
- },
- {
- select(materializedViews) {
- return filterString.length === 0
- ? materializedViews
- : materializedViews.filter((view) =>
- view.name.toLowerCase().includes(filterString.toLowerCase())
- )
- },
- }
- )
- const {
- data: foreignTables,
- error: foreignTablesError,
- isError: isErrorForeignTables,
- isPending: isLoadingForeignTables,
- isSuccess: isSuccessForeignTables,
- } = useForeignTablesQuery(
- {
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- schema: selectedSchema,
- },
- {
- select(foreignTables) {
- return filterString.length === 0
- ? foreignTables
- : foreignTables.filter((table) =>
- table.name.toLowerCase().includes(filterString.toLowerCase())
- )
- },
- }
- )
- const { data: publications } = useDatabasePublicationsQuery({
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- })
- const realtimePublication = (publications ?? []).find(
- (publication) => publication.name === 'briven_realtime'
- )
- const entities = formatAllEntities({ tables, views, materializedViews, foreignTables }).filter(
- (x) => visibleTypes.includes(x.type)
- )
- const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
- const canAddTables = canUpdateTables && !isSchemaLocked
- useShortcut(
- SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
- () => {
- searchInputRef.current?.focus()
- searchInputRef.current?.select()
- },
- { label: 'Search tables' }
- )
- useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
- setVisibleTypes(Object.values(ENTITY_TYPE))
- setFilterString('')
- })
- const error = tablesError || viewsError || materializedViewsError || foreignTablesError
- const isError = isErrorTables || isErrorViews || isErrorMaterializedViews || isErrorForeignTables
- const isLoading =
- isLoadingTables || isLoadingViews || isLoadingMaterializedViews || isLoadingForeignTables
- const isSuccess =
- isSuccessTables && isSuccessViews && isSuccessMaterializedViews && isSuccessForeignTables
- const formatTooltipText = (entityType: string) => {
- const text =
- Object.entries(ENTITY_TYPE)
- .find(([, value]) => value === entityType)?.[0]
- ?.toLowerCase()
- ?.split('_')
- ?.join(' ') || ''
- // Return sentence case (capitalize first letter only)
- return text.charAt(0).toUpperCase() + text.slice(1)
- }
- return (
- <div className="flex flex-col gap-y-4">
- <div className="flex flex-col lg:flex-row lg:items-center gap-2 flex-wrap">
- <div className="flex gap-2 items-center">
- <Shortcut
- id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
- onTrigger={() => setSchemaSelectorOpen(true)}
- side="bottom"
- tooltipOpen={schemaSelectorOpen ? false : undefined}
- >
- <SchemaSelector
- className="grow lg:grow-0 w-[180px]"
- size="tiny"
- showError={false}
- selectedSchemaName={selectedSchema}
- onSelectSchema={setSelectedSchema}
- open={schemaSelectorOpen}
- onOpenChange={setSchemaSelectorOpen}
- />
- </Shortcut>
- <Popover>
- <PopoverTrigger asChild>
- <Button
- size="tiny"
- type={visibleTypes.length !== 5 ? 'default' : 'dashed'}
- className="px-1"
- icon={<Filter />}
- />
- </PopoverTrigger>
- <PopoverContent className="p-0 w-56" side="bottom" align="center">
- <div className="px-3 pt-3 pb-2 flex flex-col gap-y-2">
- <p className="text-xs">Show entity types</p>
- <div className="flex flex-col">
- {Object.entries(ENTITY_TYPE).map(([key, value]) => (
- <div key={key} className="group flex items-center justify-between py-0.5">
- <div className="flex items-center gap-x-2">
- <Checkbox
- id={key}
- name={key}
- checked={visibleTypes.includes(value)}
- onCheckedChange={() => {
- if (visibleTypes.includes(value)) {
- setVisibleTypes(visibleTypes.filter((y) => y !== value))
- } else {
- setVisibleTypes(visibleTypes.concat([value]))
- }
- }}
- />
- <Label htmlFor={key} className="capitalize text-xs">
- {key.toLowerCase().replace('_', ' ')}
- </Label>
- </div>
- <Button
- size="tiny"
- type="default"
- onClick={() => setVisibleTypes([value])}
- className="transition opacity-0 group-hover:opacity-100 h-auto px-1 py-0.5"
- >
- Select only
- </Button>
- </div>
- ))}
- </div>
- </div>
- </PopoverContent>
- </Popover>
- </div>
- <div className="flex grow justify-between gap-2 items-center">
- <Input
- ref={searchInputRef}
- size="tiny"
- containerClassName="grow lg:grow-0 w-52"
- placeholder="Search for a table"
- value={filterString}
- onChange={(e) => setFilterString(e.target.value)}
- onKeyDown={onSearchInputEscape(filterString, setFilterString)}
- icon={<Search />}
- />
- {!isSchemaLocked &&
- (canAddTables ? (
- <Shortcut
- id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
- label="Create new table"
- onTrigger={() => onAddTable()}
- side="bottom"
- >
- <Button className="w-auto ml-auto" icon={<Plus />} onClick={() => onAddTable()}>
- New table
- </Button>
- </Shortcut>
- ) : (
- <ButtonTooltip
- className="w-auto ml-auto"
- icon={<Plus />}
- disabled
- tooltip={{
- content: {
- side: 'bottom',
- text: 'You need additional permissions to create tables',
- },
- }}
- >
- New table
- </ButtonTooltip>
- ))}
- </div>
- </div>
- {isSchemaLocked && <ProtectedSchemaWarning schema={selectedSchema} entity="tables" />}
- {isLoading && <GenericSkeletonLoader />}
- {isError && <AlertError error={error} subject="Failed to retrieve tables" />}
- {isSuccess && (
- <div className="w-full">
- <Card>
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead key="icon" className="w-0 px-0!" />
- <TableHead key="name" className="max-w-[160px] sm:max-w-[280px]">
- Name
- </TableHead>
- <TableHead key="columns">Columns</TableHead>
- <TableHead key="rows">Rows (Estimated)</TableHead>
- <TableHead key="size">Size (Estimated)</TableHead>
- <TableHead key="realtime">Realtime</TableHead>
- <TableHead key="buttons"></TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- <>
- {entities.length === 0 && filterString.length === 0 && (
- <TableRow key={selectedSchema}>
- <TableCell colSpan={7}>
- {visibleTypes.length === 0 ? (
- <>
- <p className="text-sm text-foreground">
- Please select at least one entity type to filter with
- </p>
- <p className="text-sm text-foreground-light">
- There are currently no results based on the filter that you have
- applied
- </p>
- </>
- ) : (
- <>
- <p className="text-sm text-foreground">No tables created yet</p>
- <p className="text-sm text-foreground-light">
- There are no{' '}
- {visibleTypes.length === 5
- ? 'tables'
- : visibleTypes.length === 1
- ? `${formatTooltipText(visibleTypes[0])}s`
- : `${visibleTypes
- .slice(0, -1)
- .map((x) => `${formatTooltipText(x)}s`)
- .join(
- ', '
- )}, and ${formatTooltipText(visibleTypes[visibleTypes.length - 1])}s`}{' '}
- found in the schema "{selectedSchema}"
- </p>
- </>
- )}
- </TableCell>
- </TableRow>
- )}
- {entities.length === 0 && filterString.length > 0 && (
- <TableRow key={selectedSchema}>
- <TableCell colSpan={7}>
- <p className="text-sm text-foreground">No results found</p>
- <p className="text-sm text-foreground-light">
- Your search for "{filterString}" did not return any results
- </p>
- </TableCell>
- </TableRow>
- )}
- {entities.length > 0 &&
- entities.map((x) => (
- <TableRow key={x.id}>
- <TableCell className="w-0 pl-5! pr-1!">
- <Tooltip>
- <TooltipTrigger asChild className="cursor-default">
- <div className="flex w-4 justify-center">
- <EntityTypeIcon type={x.type} />
- </div>
- </TooltipTrigger>
- <TooltipContent side="bottom">
- {formatTooltipText(x.type)}
- </TooltipContent>
- </Tooltip>
- </TableCell>
- <TableCell className="max-w-[160px] sm:max-w-[280px]">
- <div className="flex min-w-0 flex-col">
- {/* only show tooltips if required, to reduce noise */}
- {x.name.length > 20 ? (
- <Tooltip disableHoverableContent={true}>
- <TooltipTrigger
- asChild
- className="max-w-[95%] overflow-hidden text-ellipsis whitespace-nowrap"
- >
- <p>{x.name}</p>
- </TooltipTrigger>
- <TooltipContent side="bottom">{x.name}</TooltipContent>
- </Tooltip>
- ) : (
- <p>{x.name}</p>
- )}
- {x.comment !== null ? (
- <span
- className="max-w-md truncate text-foreground-lighter"
- title={x.comment}
- >
- {x.comment}
- </span>
- ) : null}
- </div>
- </TableCell>
- <TableCell>
- <p className="text-foreground-light">
- {x.columns.length.toLocaleString()}
- </p>
- </TableCell>
- <TableCell>
- {x.rows !== undefined ? (
- <p className="text-foreground-light">{x.rows.toLocaleString()}</p>
- ) : (
- <p className="text-foreground-muted">–</p>
- )}
- </TableCell>
- <TableCell>
- {x.size !== undefined ? (
- <p className="text-foreground-light">{x.size}</p>
- ) : (
- <p className="text-foreground-muted">–</p>
- )}
- </TableCell>
- <TableCell>
- {(realtimePublication?.tables ?? []).find(
- (table) => table.id === x.id
- ) ? (
- <div className="flex items-center gap-x-2">
- <Check size={16} strokeWidth={2} className="text-brand-link" />
- <p className="text-foreground-light">Enabled</p>
- </div>
- ) : (
- <div className="flex items-center gap-x-2">
- <X size={16} strokeWidth={2} className="text-foreground-muted" />
- <p className="text-foreground-lighter">Disabled</p>
- </div>
- )}
- </TableCell>
- <TableCell>
- <div className="flex justify-end gap-2">
- <Button asChild type="default">
- <Link href={`/project/${ref}/database/tables/${x.id}`}>
- View columns
- </Link>
- </Button>
- {!isSchemaLocked && (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button type="default" className="px-1" icon={<MoreVertical />} />
- </DropdownMenuTrigger>
- <DropdownMenuContent side="bottom" align="end" className="w-40">
- <DropdownMenuItem
- className="flex items-center space-x-2"
- onClick={() =>
- router.push(
- buildTableEditorUrl({
- projectRef: project?.ref,
- tableId: x.id,
- schema: x.schema,
- })
- )
- }
- onMouseEnter={() =>
- prefetchEditorTablePage({
- id: x.id ? String(x.id) : undefined,
- })
- }
- >
- <Eye size={12} />
- <p>View in Table Editor</p>
- </DropdownMenuItem>
- {x.type === ENTITY_TYPE.TABLE && (
- <>
- <DropdownMenuSeparator />
- <DropdownMenuItemTooltip
- className="gap-x-2"
- disabled={!canUpdateTables}
- onClick={() => {
- if (canUpdateTables) onEditTable(x)
- }}
- tooltip={{
- content: {
- side: 'left',
- text: 'You need additional permissions to edit this table',
- },
- }}
- >
- <Edit size={12} />
- <p>Edit table</p>
- </DropdownMenuItemTooltip>
- <DropdownMenuItemTooltip
- key="duplicate-table"
- className="gap-x-2"
- disabled={!canUpdateTables}
- onClick={() => {
- if (canUpdateTables) onDuplicateTable(x)
- }}
- tooltip={{
- content: {
- side: 'left',
- text: 'You need additional permissions to duplicate tables',
- },
- }}
- >
- <Copy size={12} />
- <span>Duplicate Table</span>
- </DropdownMenuItemTooltip>
- <DropdownMenuSeparator />
- <DropdownMenuItemTooltip
- disabled={!canUpdateTables || isSchemaLocked}
- className="gap-x-2"
- onClick={() => {
- if (canUpdateTables && !isSchemaLocked) {
- onDeleteTable({ ...x, schema: selectedSchema })
- }
- }}
- tooltip={{
- content: {
- side: 'left',
- text: 'You need additional permissions to delete tables',
- },
- }}
- >
- <Trash size={12} />
- <p>Delete table</p>
- </DropdownMenuItemTooltip>
- </>
- )}
- </DropdownMenuContent>
- </DropdownMenu>
- )}
- </div>
- </TableCell>
- </TableRow>
- ))}
- </>
- </TableBody>
- <TableFooter className="font-normal">
- <TableRow className="border-b-0 [&>td]:hover:bg-inherit">
- <TableCell colSpan={7} className="text-foreground-muted">
- {entities.length} {entities.length === 1 ? 'table' : 'tables'}
- </TableCell>
- </TableRow>
- </TableFooter>
- </Table>
- </Card>
- </div>
- )}
- </div>
- )
- }
|