ColumnList.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. import { PermissionAction } from '@supabase/shared-types/out/constants'
  2. import { useParams } from 'common'
  3. import { noop } from 'lodash'
  4. import {
  5. Braces,
  6. Calendar,
  7. DiamondIcon,
  8. Fingerprint,
  9. Hash,
  10. Key,
  11. Link as LinkIcon,
  12. ListPlus,
  13. MoreVertical,
  14. Plus,
  15. Search,
  16. ToggleRight,
  17. Trash,
  18. Type,
  19. } from 'lucide-react'
  20. import { useState } from 'react'
  21. import {
  22. Button,
  23. Card,
  24. cn,
  25. DropdownMenu,
  26. DropdownMenuContent,
  27. DropdownMenuTrigger,
  28. Table,
  29. TableBody,
  30. TableCell,
  31. TableFooter,
  32. TableHead,
  33. TableHeader,
  34. TableRow,
  35. Tooltip,
  36. TooltipContent,
  37. TooltipTrigger,
  38. } from 'ui'
  39. import { Input } from 'ui-patterns/DataInputs/Input'
  40. import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
  41. import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning'
  42. import {
  43. getColumnTypeAffordance,
  44. getForeignKeyColumnNames,
  45. getPrimaryKeyColumnNames,
  46. getUniqueIndexColumnNames,
  47. } from './ColumnList.utils'
  48. import { ConstraintToken } from './ConstraintToken'
  49. import { displayColumnType } from '@/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils'
  50. import AlertError from '@/components/ui/AlertError'
  51. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  52. import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
  53. import { NoSearchResults } from '@/components/ui/NoSearchResults'
  54. import { useTableEditorQuery } from '@/data/table-editor/table-editor-query'
  55. import { isTableLike } from '@/data/table-editor/table-editor-types'
  56. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  57. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  58. import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
  59. import type { SafePostgresColumn } from '@/lib/postgres-types'
  60. const getColumnTypeAffordancePresentation = (column: SafePostgresColumn) => {
  61. const { kind, label } = getColumnTypeAffordance(column.format)
  62. const iconClassName = 'text-foreground-muted'
  63. switch (kind) {
  64. case 'number':
  65. return {
  66. icon: <Hash size={14} className={iconClassName} strokeWidth={1.5} />,
  67. label,
  68. }
  69. case 'time':
  70. return {
  71. icon: <Calendar size={14} className={iconClassName} strokeWidth={1.5} />,
  72. label,
  73. }
  74. case 'text':
  75. return {
  76. icon: <Type size={14} className={iconClassName} strokeWidth={1.5} />,
  77. label,
  78. }
  79. case 'json':
  80. return {
  81. icon: <Braces size={14} className={iconClassName} strokeWidth={1.5} />,
  82. label,
  83. }
  84. case 'bool':
  85. return {
  86. icon: <ToggleRight size={14} className={iconClassName} strokeWidth={1.5} />,
  87. label,
  88. }
  89. default:
  90. return {
  91. icon: <ListPlus size={16} className={iconClassName} strokeWidth={1.5} />,
  92. label,
  93. }
  94. }
  95. }
  96. interface ColumnListProps {
  97. onAddColumn: () => void
  98. onEditColumn: (column: SafePostgresColumn) => void
  99. onDeleteColumn: (column: SafePostgresColumn) => void
  100. }
  101. export const ColumnList = ({
  102. onAddColumn = noop,
  103. onEditColumn = noop,
  104. onDeleteColumn = noop,
  105. }: ColumnListProps) => {
  106. const { id: _id } = useParams()
  107. const id = _id ? Number(_id) : undefined
  108. const { data: project } = useSelectedProjectQuery()
  109. const {
  110. data: selectedTable,
  111. error,
  112. isError,
  113. isPending: isLoading,
  114. isSuccess,
  115. } = useTableEditorQuery({
  116. projectRef: project?.ref,
  117. connectionString: project?.connectionString,
  118. id,
  119. })
  120. const [filterString, setFilterString] = useState<string>('')
  121. const isTableEntity = isTableLike(selectedTable)
  122. const tableConstraintSource = isTableEntity ? selectedTable : undefined
  123. const primaryKeyColumns = getPrimaryKeyColumnNames(tableConstraintSource)
  124. const foreignKeyColumns = getForeignKeyColumnNames(tableConstraintSource)
  125. const uniqueIndexColumns = getUniqueIndexColumnNames(tableConstraintSource)
  126. const columns =
  127. (filterString.length === 0
  128. ? (selectedTable?.columns ?? [])
  129. : selectedTable?.columns?.filter((column) =>
  130. column.name.toLowerCase().includes(filterString.toLowerCase())
  131. )) ?? []
  132. const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedTable?.schema ?? '' })
  133. const { can: canUpdateColumns } = useAsyncCheckPermissions(
  134. PermissionAction.TENANT_SQL_ADMIN_WRITE,
  135. 'columns'
  136. )
  137. const deleteColumnTooltipText = !canUpdateColumns
  138. ? 'Additional permissions required to delete column'
  139. : undefined
  140. return (
  141. <div className="space-y-4">
  142. <div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
  143. <div className="w-full lg:w-52">
  144. <Input
  145. size="tiny"
  146. placeholder="Filter columns"
  147. value={filterString}
  148. onChange={(e) => setFilterString(e.target.value)}
  149. icon={<Search />}
  150. />
  151. </div>
  152. {!isSchemaLocked && isTableEntity && (
  153. <ButtonTooltip
  154. icon={<Plus />}
  155. disabled={!canUpdateColumns}
  156. onClick={() => onAddColumn()}
  157. tooltip={{
  158. content: {
  159. side: 'bottom',
  160. text: !canUpdateColumns
  161. ? 'You need additional permissions to create columns'
  162. : undefined,
  163. },
  164. }}
  165. >
  166. New column
  167. </ButtonTooltip>
  168. )}
  169. </div>
  170. {isSchemaLocked && (
  171. <ProtectedSchemaWarning schema={selectedTable?.schema ?? ''} entity="columns" />
  172. )}
  173. <Card>
  174. {isLoading ? (
  175. <div className="p-4">
  176. <GenericSkeletonLoader />
  177. </div>
  178. ) : (
  179. <Table>
  180. <TableHeader>
  181. <TableRow>
  182. <TableHead className="w-0 px-0!" />
  183. <TableHead
  184. className={cn(columns.length === 0 ? 'text-foreground-muted' : undefined)}
  185. >
  186. Name
  187. </TableHead>
  188. <TableHead className={columns.length === 0 ? 'text-foreground-muted' : undefined}>
  189. Type
  190. </TableHead>
  191. <TableHead className={columns.length === 0 ? 'text-foreground-muted' : undefined}>
  192. Constraints
  193. </TableHead>
  194. <TableHead />
  195. </TableRow>
  196. </TableHeader>
  197. <TableBody>
  198. {isError && (
  199. <TableRow className="[&>td]:hover:bg-inherit">
  200. <TableCell colSpan={5}>
  201. <AlertError
  202. error={error}
  203. subject={`Failed to retrieve columns for table "${selectedTable?.schema}.${selectedTable?.name}"`}
  204. />
  205. </TableCell>
  206. </TableRow>
  207. )}
  208. {isSuccess && columns.length === 0 && filterString.length > 0 && (
  209. <TableRow className="[&>td]:hover:bg-inherit">
  210. <TableCell colSpan={5}>
  211. <NoSearchResults
  212. withinTableCell
  213. searchString={filterString}
  214. onResetFilter={() => setFilterString('')}
  215. />
  216. </TableCell>
  217. </TableRow>
  218. )}
  219. {isSuccess && columns.length === 0 && filterString.length === 0 && (
  220. <TableRow className="[&>td]:hover:bg-inherit">
  221. <TableCell colSpan={5}>
  222. <p className="text-sm text-foreground">No columns created yet</p>
  223. <p className="text-sm text-foreground-light">
  224. There are no columns in "{selectedTable?.schema}.{selectedTable?.name}"
  225. </p>
  226. </TableCell>
  227. </TableRow>
  228. )}
  229. {isSuccess &&
  230. columns.map((column) => {
  231. const { icon: TypeIcon, label: typeLabel } =
  232. getColumnTypeAffordancePresentation(column)
  233. const constraintTokens = [
  234. primaryKeyColumns.has(column.name) ? (
  235. <ConstraintToken
  236. key="primary"
  237. icon={<Key size={12} strokeWidth={1.7} className="shrink-0" />}
  238. label="Primary"
  239. variant="primary"
  240. />
  241. ) : null,
  242. foreignKeyColumns.has(column.name) ? (
  243. <ConstraintToken
  244. key="foreign-key"
  245. icon={
  246. <LinkIcon
  247. size={12}
  248. strokeWidth={1.7}
  249. className="shrink-0 text-foreground-muted"
  250. />
  251. }
  252. label="Foreign key"
  253. />
  254. ) : null,
  255. column.is_unique || uniqueIndexColumns.has(column.name) ? (
  256. <ConstraintToken
  257. key="unique"
  258. icon={
  259. <Fingerprint
  260. size={12}
  261. strokeWidth={1.7}
  262. className="shrink-0 text-foreground-light"
  263. />
  264. }
  265. label="Unique"
  266. />
  267. ) : null,
  268. column.is_identity ? (
  269. <ConstraintToken
  270. key="identity"
  271. icon={
  272. <Hash
  273. size={12}
  274. strokeWidth={1.7}
  275. className="shrink-0 text-foreground-lighter"
  276. />
  277. }
  278. label="Identity"
  279. />
  280. ) : null,
  281. <ConstraintToken
  282. key="nullability"
  283. icon={
  284. <DiamondIcon
  285. size={12}
  286. strokeWidth={1.7}
  287. className="shrink-0"
  288. fill={column.is_nullable ? 'none' : 'currentColor'}
  289. />
  290. }
  291. label={column.is_nullable ? 'Nullable' : 'Non-nullable'}
  292. variant="secondary"
  293. />,
  294. ].filter(Boolean)
  295. return (
  296. <TableRow key={column.name}>
  297. <TableCell className="w-0 pl-5! pr-1!">
  298. <Tooltip>
  299. <TooltipTrigger asChild className="cursor-default" aria-label={typeLabel}>
  300. <div className="flex w-4 justify-center">{TypeIcon}</div>
  301. </TooltipTrigger>
  302. <TooltipContent side="bottom">
  303. <div className="flex flex-col">
  304. <span>{column.data_type}</span>
  305. {column.format !== column.data_type && (
  306. <span className="text-xs text-foreground-light">
  307. {displayColumnType(
  308. column.format,
  309. column.format_schema,
  310. column.data_type === 'ARRAY'
  311. )}
  312. </span>
  313. )}
  314. </div>
  315. </TooltipContent>
  316. </Tooltip>
  317. </TableCell>
  318. <TableCell className="max-w-[160px] sm:max-w-[280px]">
  319. <div className="flex min-w-0 flex-col">
  320. <p>{column.name}</p>
  321. {column.comment !== null ? (
  322. <span
  323. className="max-w-md truncate text-foreground-lighter"
  324. title={column.comment}
  325. >
  326. {column.comment}
  327. </span>
  328. ) : null}
  329. </div>
  330. </TableCell>
  331. <TableCell>
  332. <p className="text-foreground-lighter">
  333. {displayColumnType(
  334. column.format,
  335. column.format_schema,
  336. column.data_type === 'ARRAY'
  337. )}
  338. </p>
  339. </TableCell>
  340. <TableCell>
  341. <div className="flex flex-wrap gap-1.5">{constraintTokens}</div>
  342. </TableCell>
  343. <TableCell className="text-right">
  344. {!isSchemaLocked && isTableEntity && (
  345. <div className="flex justify-end gap-2">
  346. <ButtonTooltip
  347. type="default"
  348. disabled={!canUpdateColumns}
  349. onClick={() => onEditColumn(column)}
  350. tooltip={{
  351. content: {
  352. side: 'bottom',
  353. text: !canUpdateColumns
  354. ? 'Additional permissions required to edit column'
  355. : undefined,
  356. },
  357. }}
  358. >
  359. Edit
  360. </ButtonTooltip>
  361. <DropdownMenu>
  362. <DropdownMenuTrigger asChild>
  363. <Button type="default" className="px-1" icon={<MoreVertical />} />
  364. </DropdownMenuTrigger>
  365. <DropdownMenuContent side="bottom" align="end" className="w-32">
  366. <DropdownMenuItemTooltip
  367. disabled={!canUpdateColumns}
  368. onClick={() => onDeleteColumn(column)}
  369. className="gap-x-2"
  370. tooltip={{
  371. content: {
  372. side: 'left',
  373. text: deleteColumnTooltipText,
  374. },
  375. }}
  376. >
  377. <Trash size={12} />
  378. <p>Delete column</p>
  379. </DropdownMenuItemTooltip>
  380. </DropdownMenuContent>
  381. </DropdownMenu>
  382. </div>
  383. )}
  384. </TableCell>
  385. </TableRow>
  386. )
  387. })}
  388. </TableBody>
  389. {isSuccess && (
  390. <TableFooter className="font-normal">
  391. <TableRow className="border-b-0 [&>td]:hover:bg-inherit">
  392. <TableCell colSpan={5} className="text-foreground-muted">
  393. {columns.length} {columns.length === 1 ? 'column' : 'columns'}
  394. </TableCell>
  395. </TableRow>
  396. </TableFooter>
  397. )}
  398. </Table>
  399. )}
  400. </Card>
  401. </div>
  402. )
  403. }