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