diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx index 622161c0f39..4848ecb2c6b 100644 --- a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -3,6 +3,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { NoSearchResults } from 'components/ui/NoSearchResults' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { isTableLike } from 'data/table-editor/table-editor-types' @@ -10,14 +11,29 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { noop } from 'lodash' -import { Check, Edit, MoreVertical, Plus, Search, Trash, X } from 'lucide-react' -import { useState } from 'react' +import { + Braces, + Calendar, + DiamondIcon, + Fingerprint, + Hash, + Key, + Link as LinkIcon, + ListPlus, + MoreVertical, + Plus, + Search, + ToggleRight, + Trash, + Type, +} from 'lucide-react' +import { useState, type ReactNode } from 'react' import { Button, Card, + cn, DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuTrigger, Table, TableBody, @@ -34,6 +50,51 @@ import { Input } from 'ui-patterns/DataInputs/Input' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' +import { + getColumnTypeAffordance, + getForeignKeyColumnNames, + getPrimaryKeyColumnNames, + getUniqueIndexColumnNames, +} from './ColumnList.utils' +import { ConstraintToken } from './ConstraintToken' + +const getColumnTypeAffordancePresentation = (column: PostgresColumn) => { + const { kind, label } = getColumnTypeAffordance(column.format) + const iconClassName = 'text-foreground-muted' + + switch (kind) { + case 'number': + return { + icon: , + label, + } + case 'time': + return { + icon: , + label, + } + case 'text': + return { + icon: , + label, + } + case 'json': + return { + icon: , + label, + } + case 'bool': + return { + icon: , + label, + } + default: + return { + icon: , + label, + } + } +} interface ColumnListProps { onAddColumn: () => void @@ -64,6 +125,10 @@ export const ColumnList = ({ const [filterString, setFilterString] = useState('') const isTableEntity = isTableLike(selectedTable) + const tableConstraintSource = isTableEntity ? selectedTable : undefined + const primaryKeyColumns = getPrimaryKeyColumnNames(tableConstraintSource) + const foreignKeyColumns = getForeignKeyColumnNames(tableConstraintSource) + const uniqueIndexColumns = getUniqueIndexColumnNames(tableConstraintSource) const columns = (filterString.length === 0 @@ -77,6 +142,9 @@ export const ColumnList = ({ PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns' ) + const deleteColumnTooltipText = !canUpdateColumns + ? 'Additional permissions required to delete column' + : undefined return (
@@ -122,25 +190,23 @@ export const ColumnList = ({ - + + + Name - Data Type + Type - Format - - - Nullable + Constraints + {isError && ( @@ -177,86 +243,156 @@ export const ColumnList = ({ )} {isSuccess && - columns.length > 0 && - columns.map((column) => ( - - -
-

{column.name}

- {column.comment !== null ? ( - - {column.comment} - - ) : null} -
-
- - {column.data_type} - - - {column.format} - - - {column.is_nullable ? ( -
- + columns.map((column) => { + const { icon: TypeIcon, label: typeLabel } = + getColumnTypeAffordancePresentation(column) + + const constraintTokens = [ + primaryKeyColumns.has(column.name) ? ( + } + label="Primary" + variant="primary" + /> + ) : null, + foreignKeyColumns.has(column.name) ? ( + + } + label="Foreign key" + /> + ) : null, + column.is_unique || uniqueIndexColumns.has(column.name) ? ( + + } + label="Unique" + /> + ) : null, + column.is_identity ? ( + + } + label="Identity" + /> + ) : null, + + } + label={column.is_nullable ? 'Nullable' : 'Non-nullable'} + variant="secondary" + />, + ].filter(Boolean) + + return ( + + + + +
{TypeIcon}
+
+ +
+ {column.data_type} + {column.format !== column.data_type && ( + + {column.format} + + )} +
+
+
+
+ +
+

{column.name}

+ {column.comment !== null ? ( + + {column.comment} + + ) : null}
- ) : ( -
- -
- )} -
- - {!isSchemaLocked && isTableEntity && ( -
- onEditColumn(column)} - tooltip={{ - content: { - side: 'bottom', - text: !canUpdateColumns - ? 'Additional permissions required to edit column' - : undefined, - }, - }} - > - Edit - - - -
- )} -
-
- ))} + + +

{column.format}

+
+ +
{constraintTokens}
+
+ + {!isSchemaLocked && isTableEntity && ( +
+ onEditColumn(column)} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateColumns + ? 'Additional permissions required to edit column' + : undefined, + }, + }} + > + Edit + + + +
+ )} +
+ + ) + })} {isSuccess && ( diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.utils.test.ts b/apps/studio/components/interfaces/Database/Tables/ColumnList.utils.test.ts new file mode 100644 index 00000000000..5167108f7f3 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.utils.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' + +import { + getColumnTypeAffordance, + getForeignKeyColumnNames, + getPrimaryKeyColumnNames, + getUniqueIndexColumnNames, +} from './ColumnList.utils' + +describe('ColumnList.utils', () => { + it('normalises quoted array formats before resolving the affordance kind', () => { + expect(getColumnTypeAffordance('"uuid"[]')).toEqual({ + kind: 'text', + label: 'Text', + }) + }) + + it('maps recognised Postgres formats to their affordance labels', () => { + expect(getColumnTypeAffordance('timestamptz')).toEqual({ + kind: 'time', + label: 'Date / time', + }) + expect(getColumnTypeAffordance('jsonb')).toEqual({ + kind: 'json', + label: 'JSON', + }) + }) + + it('falls back to the other affordance for unrecognised formats', () => { + expect(getColumnTypeAffordance('citext')).toEqual({ + kind: 'other', + label: 'Other', + }) + }) + + it('derives only source-table foreign key column names', () => { + const table = { + schema: 'public', + name: 'orders', + primary_keys: [{ name: 'id' }], + relationships: [ + { + source_schema: 'public', + source_table_name: 'orders', + source_column_name: 'customer_id', + target_table_schema: 'public', + target_table_name: 'customers', + target_column_name: 'id', + }, + { + source_schema: 'public', + source_table_name: 'customers', + source_column_name: 'account_id', + target_table_schema: 'public', + target_table_name: 'accounts', + target_column_name: 'id', + }, + ], + unique_indexes: [{ columns: ['reference'] }, { columns: ['customer_id', 'reference'] }], + } as const + + expect([...getPrimaryKeyColumnNames(table)]).toEqual(['id']) + expect([...getForeignKeyColumnNames(table)]).toEqual(['customer_id']) + expect([...getUniqueIndexColumnNames(table)]).toEqual(['reference']) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.utils.ts b/apps/studio/components/interfaces/Database/Tables/ColumnList.utils.ts new file mode 100644 index 00000000000..bfd03662aa5 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.utils.ts @@ -0,0 +1,78 @@ +import { POSTGRES_DATA_TYPE_OPTIONS } from 'components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.constants' + +type TableConstraintSource = { + schema: string + name: string + primary_keys: ReadonlyArray<{ name: string }> + relationships: ReadonlyArray<{ + source_schema: string + source_table_name: string + source_column_name: string + }> + unique_indexes?: ReadonlyArray<{ columns: ReadonlyArray }> +} + +export type ColumnAffordanceKind = 'number' | 'time' | 'text' | 'json' | 'bool' | 'other' + +export interface ColumnTypeAffordance { + kind: ColumnAffordanceKind + label: string +} + +const COLUMN_AFFORDANCE_LABELS: Record = { + number: 'Numeric', + time: 'Date / time', + text: 'Text', + json: 'JSON', + bool: 'Boolean', + other: 'Other', +} + +const normalizeColumnFormat = (format: string) => format.replaceAll('"', '').replace(/\[\]$/, '') + +export function getColumnTypeAffordance(format: string): ColumnTypeAffordance { + const normalizedFormat = normalizeColumnFormat(format) + const optionType = POSTGRES_DATA_TYPE_OPTIONS.find( + (option) => option.name === normalizedFormat + )?.type + + switch (optionType) { + case 'number': + case 'time': + case 'text': + case 'json': + case 'bool': + return { kind: optionType, label: COLUMN_AFFORDANCE_LABELS[optionType] } + default: + return { kind: 'other', label: COLUMN_AFFORDANCE_LABELS.other } + } +} + +export function getPrimaryKeyColumnNames(table?: TableConstraintSource) { + return new Set(table?.primary_keys.map((primaryKey) => primaryKey.name) ?? []) +} + +export function getForeignKeyColumnNames(table?: TableConstraintSource) { + if (!table) { + return new Set() + } + + const { schema, name, relationships } = table + + return new Set( + relationships + .filter( + (relationship) => + relationship.source_schema === schema && relationship.source_table_name === name + ) + .map((relationship) => relationship.source_column_name) + ) +} + +export function getUniqueIndexColumnNames(table?: TableConstraintSource) { + return new Set( + table?.unique_indexes + ?.filter((uniqueIndex) => uniqueIndex.columns.length === 1) + .flatMap((uniqueIndex) => uniqueIndex.columns) ?? [] + ) +} diff --git a/apps/studio/components/interfaces/Database/Tables/ConstraintToken.tsx b/apps/studio/components/interfaces/Database/Tables/ConstraintToken.tsx new file mode 100644 index 00000000000..6bcb0db01b1 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Tables/ConstraintToken.tsx @@ -0,0 +1,44 @@ +import { ReactNode } from 'react' +import { cn } from 'ui' + +interface ConstraintTokenProps { + icon?: ReactNode + label: string + variant?: 'default' | 'secondary' | 'primary' +} + +const constraintTokenClassName = cn( + 'inline-flex items-center justify-center rounded-md text-center font-mono uppercase whitespace-nowrap font-medium', + 'tracking-[0.06em] text-[11px] leading-[1.1] px-[5.5px] h-[21px]', + 'transition-all border' +) + +export const ConstraintToken = ({ icon, label, variant = 'default' }: ConstraintTokenProps) => { + const tokenToneClassName = + variant === 'primary' + ? 'bg-brand bg-opacity-10 text-brand-600 border-brand-500' + : variant === 'default' + ? 'bg-surface-75 text-foreground-light border-strong' + : 'bg-surface-75 bg-opacity-50 text-foreground-light border-strong' + + return ( +
+ {icon && ( + + {icon} + + )} + + {label} + +
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index a8bf7d7106e..8e9e0b69d60 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -306,22 +306,14 @@ export const TableList = ({
- + Name - - Columns - - - Rows (Estimated) - - - Size (Estimated) - - - Realtime Enabled - + Columns + Rows (Estimated) + Size (Estimated) + Realtime @@ -375,10 +367,12 @@ export const TableList = ({ {entities.length > 0 && entities.map((x) => ( - + - - + +
+ +
{formatTooltipText(x.type)} @@ -412,35 +406,37 @@ export const TableList = ({ ) : null}
- +

{x.columns.length.toLocaleString()}

- + {x.rows !== undefined ? (

{x.rows.toLocaleString()}

) : (

–

)}
- + {x.size !== undefined ? (

{x.size}

) : (

–

)}
- + {(realtimePublication?.tables ?? []).find( (table) => table.id === x.id ) ? ( -
- +
+ +

Enabled

) : ( -
- +
+ +

Disabled

)}