mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
Improve Table Editor design and accessibility (#50797)
## Problem Controls in the new table sheet's Columns section had inconsistent styling, alignment and focus states. Some icon-only actions were faint or lacked useful accessible names and tooltips, while related controls used different dropdown treatments. <img width="1428" height="250" alt="image" src="https://github.com/user-attachments/assets/9d1ad80d-2de0-4736-8ddb-0260e10ddacc" /> ## Solution - Align the column headers and fields on the same grid, and keep the foreign key action inside the name field in every state. - Give the drag, foreign key, suggestions, extra options and remove actions consistent button styling, sizing, hover and focus states, with accessible names and tooltips. - Move masking into Extra options and include it in the options count. Remove the separators around the former inline controls. - Use the design system's ComboboxTrigger for column types and align the Default value select's radius with it. Update the suggestions dropdown icon. - Tidy related field labels and update the affected table editor tests. The underlying column values and database behaviour are unchanged. | Original before | Original after | | --- | --- | | <img width="1498" height="392" alt="CleanShot 2026-09-24 at 13 33 40@2x" src="https://github.com/user-attachments/assets/56559e72-f289-44bc-999f-c1f3158c8f65" /> | <img width="1496" height="416" alt="CleanShot 2026-09-24 at 13 32 42@2x" src="https://github.com/user-attachments/assets/d05340fc-a460-4b5c-aaff-41127f692592" /> | ## Review instructions - Open **Table Editor → New table** and inspect the Columns section. Check that Name, Type and Default value headers align with their fields. - Tab through the row actions. Check their focus states, accessible names and tooltips, including the foreign key control with and without a relation. - Drag a column to reorder it, open Extra options and toggle masking. Check the options count and that the other settings still work. - Compare the closed Type and Default value dropdowns, including an enum default value. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Improvements** * Updated the column editor layout and controls for naming, sensitive-data settings, extra options, foreign-key editing, and column removal, with clearer tooltips and more consistent interactions. * Added accessible help tooltips for the Name and Default Value fields. * Updated column-type and suggested-value controls, including clearer guidance for choosing a suggested value. * Sensitive-data settings are now available in the extra-options menu. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
This commit is contained in:
1 parent
0d3b73794b
commit
ca28c945db
8 files changed
+371
-322
No files matched your search
+2
-2
@@ -371,7 +371,7 @@ export const ColumnEditor = ({
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
id="isArray"
|
||||
label="Define as Array"
|
||||
label="Define as array"
|
||||
description="Allow column to be defined as variable-length multidimensional arrays"
|
||||
>
|
||||
<Checkbox
|
||||
@@ -484,7 +484,7 @@ export const ColumnEditor = ({
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
id="isUnique"
|
||||
label="Is Unique"
|
||||
label="Is unique"
|
||||
description="Enforce values in the column to be unique across rows"
|
||||
>
|
||||
<Switch
|
||||
|
||||
+10
-17
@@ -1,14 +1,5 @@
|
||||
import { noop } from 'lodash'
|
||||
import {
|
||||
Calendar,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
ExternalLink,
|
||||
Hash,
|
||||
ListPlus,
|
||||
ToggleRight,
|
||||
Type,
|
||||
} from 'lucide-react'
|
||||
import { Calendar, Check, ExternalLink, Hash, ListPlus, ToggleRight, Type } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { ReactNode, useId, useState } from 'react'
|
||||
import {
|
||||
@@ -17,6 +8,7 @@ import {
|
||||
AlertTitle,
|
||||
Button,
|
||||
cn,
|
||||
ComboboxTrigger,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
@@ -188,14 +180,15 @@ const ColumnType = ({
|
||||
{showLabel && <Label className="text-foreground-light">Type</Label>}
|
||||
<Popover modal open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={error ? 'danger' : 'default'}
|
||||
role="combobox"
|
||||
size={'small'}
|
||||
<ComboboxTrigger
|
||||
size="small"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
className={cn('w-full justify-between', !hasValue && 'text-foreground-lighter')}
|
||||
iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />}
|
||||
aria-invalid={Boolean(error)}
|
||||
className={cn(
|
||||
!hasValue && 'text-foreground-lighter',
|
||||
error && 'border-destructive-400'
|
||||
)}
|
||||
title={displayValue}
|
||||
>
|
||||
{hasValue ? (
|
||||
@@ -206,7 +199,7 @@ const ColumnType = ({
|
||||
) : (
|
||||
'Choose a column type...'
|
||||
)}
|
||||
</Button>
|
||||
</ComboboxTrigger>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent id={listboxId} className="w-[460px] p-0" side="bottom" align="center">
|
||||
<Command>
|
||||
|
||||
+5
-4
@@ -6,7 +6,7 @@
|
||||
// the component over to the UI library
|
||||
|
||||
import { noop } from 'lodash'
|
||||
import { List } from 'lucide-react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -106,12 +106,13 @@ const InputWithSuggestions = ({
|
||||
<ButtonTooltip
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className="px-1 mr-0.5"
|
||||
className="w-6.5 px-0 mr-0.5"
|
||||
aria-label={suggestionsTooltip || 'Show suggestions'}
|
||||
tooltip={{
|
||||
content: { text: suggestionsTooltip || 'Suggestions', side: 'bottom' },
|
||||
content: { text: 'Choose a suggested value for this column', side: 'bottom' },
|
||||
}}
|
||||
>
|
||||
<List strokeWidth={1.5} size={14} />
|
||||
<ChevronDown className="text-foreground-lighter" strokeWidth={1.5} size={16} />
|
||||
</ButtonTooltip>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
|
||||
+297
-273
@@ -1,6 +1,6 @@
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Eye, EyeOff, GripVertical, Link, Plus, Settings, X } from 'lucide-react'
|
||||
import { GripVertical, Link, Plus, Settings, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
@@ -57,6 +59,7 @@ interface ColumnProps {
|
||||
isNewRecord: boolean
|
||||
hasForeignKeys: boolean
|
||||
hasImportContent: boolean
|
||||
gridTemplateColumns: string
|
||||
shouldAutoFocusName?: boolean
|
||||
onUpdateColumn: (changes: Partial<ColumnField>) => void
|
||||
onRemoveColumn: () => void
|
||||
@@ -70,6 +73,7 @@ export const Column = ({
|
||||
isNewRecord = false,
|
||||
hasForeignKeys = false,
|
||||
hasImportContent = false,
|
||||
gridTemplateColumns,
|
||||
shouldAutoFocusName = false,
|
||||
onUpdateColumn,
|
||||
onRemoveColumn,
|
||||
@@ -83,6 +87,7 @@ export const Column = ({
|
||||
column.isIdentity ? 1 : 0,
|
||||
column.isUnique ? 1 : 0,
|
||||
column.isArray ? 1 : 0,
|
||||
column.isSensitiveData ? 1 : 0,
|
||||
].reduce((a, b) => a + b, 0)
|
||||
|
||||
const { data } = useForeignKeyConstraintsQuery({
|
||||
@@ -107,6 +112,21 @@ export const Column = ({
|
||||
const hasChangesInRelations = relations
|
||||
.map((r) => getRelationStatus(r))
|
||||
.some((x) => x !== undefined)
|
||||
const activeRelations = relations.filter((relation) => !relation.toRemove)
|
||||
|
||||
const onToggleSensitiveData = () => {
|
||||
const marker = '[SENSITIVE]'
|
||||
const isSensitiveData = !column.isSensitiveData
|
||||
let updatedComment = column.comment || ''
|
||||
|
||||
if (isSensitiveData && !updatedComment.includes(marker)) {
|
||||
updatedComment = `${updatedComment} ${marker}`.trim()
|
||||
} else if (!isSensitiveData) {
|
||||
updatedComment = updatedComment.replace(marker, '').trim()
|
||||
}
|
||||
|
||||
onUpdateColumn({ isSensitiveData, comment: updatedComment })
|
||||
}
|
||||
|
||||
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition } =
|
||||
useSortable({
|
||||
@@ -119,183 +139,158 @@ export const Column = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center" ref={setNodeRef} style={style}>
|
||||
<div className={`w-[5%] ${!isNewRecord ? 'hidden' : ''}`}>
|
||||
<button
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={0}
|
||||
className="opacity-50 hover:opacity-100 disabled:hover:opacity-50 transition cursor-grab text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<GripVertical size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-[30%]">
|
||||
<div className="flex w-[95%] items-center justify-between">
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<Input
|
||||
autoFocus={shouldAutoFocusName}
|
||||
aria-label="Column name"
|
||||
size="small"
|
||||
value={column.name}
|
||||
title={column.name}
|
||||
disabled={hasImportContent}
|
||||
placeholder="column_name"
|
||||
className={cn(
|
||||
'[&>div>div>div>input]:py-1.5 [&>div>div>div>input]:border-r-transparent [&>div>div>div>input]:rounded-r-none',
|
||||
hasImportContent ? 'opacity-50' : ''
|
||||
)}
|
||||
onChange={(event) => onUpdateColumn({ name: event.target.value })}
|
||||
/>
|
||||
|
||||
{relations.filter((r) => !r.toRemove).length === 0 ? (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div
|
||||
className="grid w-full items-center gap-x-1"
|
||||
ref={setNodeRef}
|
||||
style={{ ...style, gridTemplateColumns }}
|
||||
>
|
||||
{isNewRecord && (
|
||||
<div className="flex items-center justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="dashed"
|
||||
className="rounded-l-none h-[30px] py-0 px-2"
|
||||
onClick={() => onEditForeignKey()}
|
||||
>
|
||||
<Link size={12} />
|
||||
</Button>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
const SENSITIVE_DATA_MARKER = '[SENSITIVE]'
|
||||
|
||||
const isSensitive = !column.isSensitiveData
|
||||
|
||||
let updatedComment = column.comment || ''
|
||||
|
||||
if (isSensitive && !updatedComment.includes(SENSITIVE_DATA_MARKER)) {
|
||||
updatedComment = updatedComment
|
||||
? `${updatedComment} ${SENSITIVE_DATA_MARKER}`
|
||||
: SENSITIVE_DATA_MARKER
|
||||
} else if (!isSensitive) {
|
||||
updatedComment = updatedComment.replace(SENSITIVE_DATA_MARKER, '').trim()
|
||||
}
|
||||
|
||||
onUpdateColumn({ isSensitiveData: isSensitive, comment: updatedComment })
|
||||
}}
|
||||
className={cn(
|
||||
'transition cursor-pointer p-1 hover:bg-surface-100 rounded',
|
||||
|
||||
column.isSensitiveData
|
||||
? 'opacity-100 text-foreground'
|
||||
: 'opacity-50 hover:opacity-100 text-foreground-light'
|
||||
)}
|
||||
type="button"
|
||||
aria-label={
|
||||
column.isSensitiveData ? 'Marked as sensitive' : 'Not marked as sensitive'
|
||||
}
|
||||
variant="text"
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={0}
|
||||
className="w-6.5 px-0 cursor-grab text-foreground"
|
||||
icon={<GripVertical size={16} strokeWidth={2} />}
|
||||
aria-label={`Move column ${column.name}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Drag to reorder columns</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex w-[95%] items-center">
|
||||
<InputGroup className="min-w-0 flex-1">
|
||||
<InputGroupInput
|
||||
autoFocus={shouldAutoFocusName}
|
||||
aria-label="Column name"
|
||||
size="small"
|
||||
value={column.name}
|
||||
title={column.name}
|
||||
disabled={hasImportContent}
|
||||
placeholder="column_name"
|
||||
className={hasImportContent ? 'opacity-50' : undefined}
|
||||
onChange={(event) => onUpdateColumn({ name: event.target.value })}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{activeRelations.length === 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className="w-6.5 px-0 mr-0.5"
|
||||
onClick={() => onEditForeignKey()}
|
||||
aria-label={`Add foreign key for ${column.name}`}
|
||||
>
|
||||
<Link size={14} strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Reference a column in another table</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className="w-6.5 px-0 mr-0.5"
|
||||
aria-label={`Edit ${column.name} foreign keys`}
|
||||
>
|
||||
<Link size={14} strokeWidth={1.5} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
View and add foreign key relations
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
className={cn('p-0', hasChangesInRelations ? 'w-96' : 'w-72')}
|
||||
side="bottom"
|
||||
align="center"
|
||||
>
|
||||
{column.isSensitiveData ? (
|
||||
<EyeOff size={14} strokeWidth={1.5} />
|
||||
) : (
|
||||
<Eye size={14} strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<div className="text-xs px-2 pt-2">
|
||||
Involved in {activeRelations.length} foreign key
|
||||
{activeRelations.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{activeRelations.map((relation, idx) => {
|
||||
const key = String(relation?.id ?? `${column.id}-relation-${idx}`)
|
||||
|
||||
<TooltipContent side="bottom">
|
||||
{column.isSensitiveData
|
||||
? 'Data is masked in grid display. Actual data unchanged in database.'
|
||||
: 'Mark as sensitive to mask in grid display'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button className="rounded-l-none h-[30px] py-0 px-2">
|
||||
<Link size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={cn('p-0', hasChangesInRelations ? 'w-96' : 'w-72')}
|
||||
side="bottom"
|
||||
align="center"
|
||||
>
|
||||
<div className="text-xs px-2 pt-2">
|
||||
Involved in {relations.length} foreign key{relations.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{relations.map((relation, idx) => {
|
||||
const key = String(relation?.id ?? `${column.id}-relation-${idx}`)
|
||||
const status = getRelationStatus(relation)
|
||||
if (status === 'REMOVE') return null
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={key}
|
||||
value={key}
|
||||
className="cursor-pointer w-full"
|
||||
onSelect={() => onEditForeignKey(relation)}
|
||||
onClick={() => onEditForeignKey(relation)}
|
||||
>
|
||||
{status === undefined ? (
|
||||
<div className="w-full flex items-center justify-between truncate">
|
||||
{relation.name}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-x-2 truncate">
|
||||
<Badge variant={status === 'ADD' ? 'success' : 'warning'}>
|
||||
{status}
|
||||
</Badge>
|
||||
<p className="truncate">
|
||||
{relation.name || (
|
||||
<>
|
||||
To{' '}
|
||||
{relation.columns
|
||||
.filter((c) => c.source === column.name)
|
||||
.map((c) => {
|
||||
return (
|
||||
<code key={`${c.source}-${c.target}`}>
|
||||
{relation.schema}.{relation.table}.{c.target}
|
||||
</code>
|
||||
)
|
||||
})}
|
||||
{relation.columns.length > 1 && (
|
||||
return (
|
||||
<CommandItem
|
||||
key={key}
|
||||
value={key}
|
||||
className="cursor-pointer w-full"
|
||||
onSelect={() => onEditForeignKey(relation)}
|
||||
onClick={() => onEditForeignKey(relation)}
|
||||
>
|
||||
{status === undefined ? (
|
||||
<div className="w-full flex items-center justify-between truncate">
|
||||
{relation.name}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-x-2 truncate">
|
||||
<Badge variant={status === 'ADD' ? 'success' : 'warning'}>
|
||||
{status}
|
||||
</Badge>
|
||||
<p className="truncate">
|
||||
{relation.name || (
|
||||
<>
|
||||
and {relation.columns.length - 1} other column
|
||||
{relation.columns.length > 2 ? 's' : ''}
|
||||
To{' '}
|
||||
{relation.columns
|
||||
.filter((c) => c.source === column.name)
|
||||
.map((c) => (
|
||||
<code key={`${c.source}-${c.target}`}>
|
||||
{relation.schema}.{relation.table}.{c.target}
|
||||
</code>
|
||||
))}
|
||||
{relation.columns.length > 1 && (
|
||||
<>
|
||||
and {relation.columns.length - 1} other column
|
||||
{relation.columns.length > 2 ? 's' : ''}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
className="cursor-pointer w-full gap-x-2"
|
||||
onSelect={() => onEditForeignKey()}
|
||||
onClick={() => onEditForeignKey()}
|
||||
>
|
||||
<Plus size={14} strokeWidth={1.5} />
|
||||
<p>Add foreign key relation</p>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
className="cursor-pointer w-full gap-x-2"
|
||||
onSelect={() => onEditForeignKey()}
|
||||
onClick={() => onEditForeignKey()}
|
||||
>
|
||||
<Plus size={14} strokeWidth={1.5} />
|
||||
<p>Add foreign key relation</p>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[25%]">
|
||||
<div className="min-w-0">
|
||||
<div className="w-[95%]">
|
||||
<ColumnType
|
||||
value={{ format: column.format, formatSchema: column.formatSchema }}
|
||||
@@ -313,14 +308,14 @@ export const Column = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${isNewRecord ? 'w-[25%]' : 'w-[30%]'}`}>
|
||||
<div className="min-w-0">
|
||||
<div className="w-[95%]">
|
||||
<ColumnDefaultValue
|
||||
columnFields={column}
|
||||
enumTypes={enumTypes}
|
||||
showLabel={false}
|
||||
size="small"
|
||||
className={`rounded-sm lg:gap-0 ${
|
||||
className={`lg:gap-0 ${
|
||||
column.format.includes('int') && column.isIdentity ? 'opacity-50' : ''
|
||||
}`}
|
||||
data-testid={`${column.name}-default-value`}
|
||||
@@ -329,7 +324,7 @@ export const Column = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[10%]">
|
||||
<div className="min-w-0">
|
||||
<Checkbox
|
||||
aria-label="Check to make this column a primary key"
|
||||
checked={column.isPrimaryKey}
|
||||
@@ -342,113 +337,142 @@ export const Column = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${hasImportContent ? 'w-[10%]' : 'w-[0%]'}`} />
|
||||
<div className="flex w-[5%] justify-end">
|
||||
{(!column.isPrimaryKey || column.format.includes('int')) && (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
data-testid={`${column.name}-extra-options`}
|
||||
className="group flex items-center -space-x-1"
|
||||
>
|
||||
{settingsCount > 0 && (
|
||||
<div className="rounded-full bg-foreground h-4 w-4 flex items-center justify-center text-xs text-background">
|
||||
{settingsCount}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-foreground-light transition-colors group-hover:text-foreground">
|
||||
<Settings size={16} strokeWidth={1} />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<div className="flex items-center justify-center bg-surface-200 gap-y-1 py-1.5 px-3 border-b border-overlay">
|
||||
<h5 className="text-foreground">Extra options</h5>
|
||||
</div>
|
||||
{hasImportContent && <div />}
|
||||
<div className="flex justify-end">
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
data-testid={`${column.name}-extra-options`}
|
||||
variant="outline"
|
||||
aria-label={`Options for ${column.name}`}
|
||||
className="w-6.5 px-0 relative"
|
||||
icon={
|
||||
<>
|
||||
{settingsCount > 0 && (
|
||||
<div className="absolute -top-1.5 -left-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-foreground text-[10px] font-medium leading-none tabular-nums text-background">
|
||||
{settingsCount}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-foreground-light transition-colors group-hover:text-foreground">
|
||||
<Settings size={16} strokeWidth={1.75} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Set column constraints, array, and masking options
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<div className="flex items-center justify-center bg-surface-200 gap-y-1 py-1.5 px-3 border-b border-overlay">
|
||||
<h5 className="text-foreground">Extra options</h5>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-4 p-4" key={`${column.id}_configuration`}>
|
||||
{!column.isPrimaryKey && (
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
<div className="flex flex-col gap-y-4 p-4" key={`${column.id}_configuration`}>
|
||||
{!column.isPrimaryKey && (
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
id="isNullable"
|
||||
label="Is nullable"
|
||||
description="Specify if the column can assume a NULL value if no value is provided"
|
||||
>
|
||||
<Checkbox
|
||||
id="isNullable"
|
||||
label="Is Nullable"
|
||||
description="Specify if the column can assume a NULL value if no value is provided"
|
||||
>
|
||||
<Checkbox
|
||||
id="isNullable"
|
||||
checked={column.isNullable}
|
||||
onCheckedChange={() => onUpdateColumn({ isNullable: !column.isNullable })}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
{!column.isPrimaryKey && (
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
checked={column.isNullable}
|
||||
onCheckedChange={() => onUpdateColumn({ isNullable: !column.isNullable })}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
{!column.isPrimaryKey && (
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
id="isUnique"
|
||||
label="Is unique"
|
||||
description="Enforce if values in the column should be unique across rows"
|
||||
>
|
||||
<Checkbox
|
||||
id="isUnique"
|
||||
label="Is Unique"
|
||||
description="Enforce if values in the column should be unique across rows"
|
||||
>
|
||||
<Checkbox
|
||||
id="isUnique"
|
||||
checked={column.isUnique}
|
||||
onCheckedChange={() => onUpdateColumn({ isUnique: !column.isUnique })}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
{column.format.includes('int') && (
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
checked={column.isUnique}
|
||||
onCheckedChange={() => onUpdateColumn({ isUnique: !column.isUnique })}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
{column.format.includes('int') && (
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
id="isIdentity"
|
||||
label="Is Identity"
|
||||
description="Automatically assign a sequential unique number to the column"
|
||||
>
|
||||
<Checkbox
|
||||
id="isIdentity"
|
||||
label="Is Identity"
|
||||
description="Automatically assign a sequential unique number to the column"
|
||||
>
|
||||
<Checkbox
|
||||
id="isIdentity"
|
||||
checked={column.isIdentity}
|
||||
onCheckedChange={() => {
|
||||
const isIdentity = !column.isIdentity
|
||||
const isArray = isIdentity ? false : column.isArray
|
||||
onUpdateColumn({ isIdentity, isArray })
|
||||
}}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
{!column.isPrimaryKey && (
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
checked={column.isIdentity}
|
||||
onCheckedChange={() => {
|
||||
const isIdentity = !column.isIdentity
|
||||
const isArray = isIdentity ? false : column.isArray
|
||||
onUpdateColumn({ isIdentity, isArray })
|
||||
}}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
{!column.isPrimaryKey && (
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
id="defineAsArray"
|
||||
label="Define as array"
|
||||
description="Define your column as a variable-length multidimensional array"
|
||||
>
|
||||
<Checkbox
|
||||
id="defineAsArray"
|
||||
label="Define as Array"
|
||||
description="Define your column as a variable-length multidimensional array"
|
||||
>
|
||||
<Checkbox
|
||||
id="defineAsArray"
|
||||
checked={column.isArray}
|
||||
onCheckedChange={() => {
|
||||
const isArray = !column.isArray
|
||||
const isIdentity = isArray ? false : column.isIdentity
|
||||
onUpdateColumn({ isArray, isIdentity })
|
||||
}}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
checked={column.isArray}
|
||||
onCheckedChange={() => {
|
||||
const isArray = !column.isArray
|
||||
const isIdentity = isArray ? false : column.isIdentity
|
||||
onUpdateColumn({ isArray, isIdentity })
|
||||
}}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
id={`${column.id}-isSensitiveData`}
|
||||
label="Mark as sensitive"
|
||||
description="Mask values in the grid display. Database values are unchanged."
|
||||
>
|
||||
<Checkbox
|
||||
id={`${column.id}-isSensitiveData`}
|
||||
checked={column.isSensitiveData ?? false}
|
||||
onCheckedChange={onToggleSensitiveData}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{!hasImportContent && (
|
||||
<div className="flex w-[5%] justify-end">
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
aria-label="Remove column"
|
||||
className="cursor-pointer"
|
||||
onClick={() => onRemoveColumn()}
|
||||
>
|
||||
<X size={16} strokeWidth={1} />
|
||||
</button>
|
||||
<div className="flex justify-end">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
tabIndex={0}
|
||||
aria-label={`Remove column ${column.name}`}
|
||||
className="w-6.5 px-0 cursor-pointer"
|
||||
onClick={() => onRemoveColumn()}
|
||||
icon={<X size={16} strokeWidth={1.75} />}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Remove this column from the table</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+45
-14
@@ -72,6 +72,18 @@ export const ColumnManagement = ({
|
||||
const [columnIdToFocus, setColumnIdToFocus] = useState<string | null>(null)
|
||||
|
||||
const hasImportContent = !isEmpty(importContent)
|
||||
const columnGridTemplate = [
|
||||
isNewRecord && '26px',
|
||||
'minmax(0, 30fr)',
|
||||
'minmax(0, 25fr)',
|
||||
`minmax(0, ${isNewRecord ? 25 : 30}fr)`,
|
||||
'minmax(0, 10fr)',
|
||||
hasImportContent && 'minmax(0, 10fr)',
|
||||
'26px',
|
||||
!hasImportContent && '26px',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
const [primaryKeyColumns, otherColumns] = partition(
|
||||
columns,
|
||||
(column: ColumnField) => column.isPrimaryKey
|
||||
@@ -222,14 +234,24 @@ export const ColumnManagement = ({
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Headers */}
|
||||
<div className="flex w-full px-3">
|
||||
<div
|
||||
className={`grid items-center gap-x-1 w-full ${isNewRecord ? 'px-3' : ''}`}
|
||||
style={{ gridTemplateColumns: columnGridTemplate }}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
{isNewRecord && <div className="w-[5%]" />}
|
||||
<div className="w-[25%] flex items-center space-x-2">
|
||||
{isNewRecord && <div />}
|
||||
<div className="min-w-0 flex items-center space-x-2">
|
||||
<h5 className="text-xs text-foreground-lighter">Name</h5>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle size={15} strokeWidth={1.5} className="text-foreground-lighter" />
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label="Show help for column Name"
|
||||
className="w-6.5 px-0"
|
||||
icon={
|
||||
<HelpCircle size={15} strokeWidth={1.5} className="text-foreground-lighter" />
|
||||
}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="w-[300px]">
|
||||
Recommended to use lowercase and use an underscore to separate words e.g.
|
||||
@@ -237,14 +259,21 @@ export const ColumnManagement = ({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="w-[25%]">
|
||||
<div className="min-w-0">
|
||||
<h5 className="text-xs text-foreground-lighter">Type</h5>
|
||||
</div>
|
||||
<div className={`${isNewRecord ? 'w-[25%]' : 'w-[30%]'} flex items-center space-x-2`}>
|
||||
<h5 className="text-xs text-foreground-lighter">Default Value</h5>
|
||||
<div className="min-w-0 flex items-center space-x-2">
|
||||
<h5 className="text-xs text-foreground-lighter">Default value</h5>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle size={15} strokeWidth={1.5} className="text-foreground-lighter" />
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label="Show help for column Default value"
|
||||
className="w-6.5 px-0"
|
||||
icon={
|
||||
<HelpCircle size={15} strokeWidth={1.5} className="text-foreground-lighter" />
|
||||
}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="w-[300px]">
|
||||
Can either be a literal or an expression. When using an expression wrap your
|
||||
@@ -252,15 +281,15 @@ export const ColumnManagement = ({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="w-[10%]">
|
||||
<div className="min-w-0">
|
||||
<h5 className="text-xs text-foreground-lighter">Primary</h5>
|
||||
</div>
|
||||
{/* Empty space */}
|
||||
<div className={`${hasImportContent ? 'w-[10%]' : 'w-0'}`} />
|
||||
{hasImportContent && <div />}
|
||||
{/* More config button */}
|
||||
<div className="w-[5%]" />
|
||||
<div />
|
||||
{/* Delete button */}
|
||||
{!hasImportContent && <div className="w-[5%]" />}
|
||||
{!hasImportContent && <div />}
|
||||
</div>
|
||||
|
||||
{primaryKeyColumns.length > 0 && (
|
||||
@@ -286,6 +315,7 @@ export const ColumnManagement = ({
|
||||
hasForeignKeys={checkIfHaveForeignKeys(column)}
|
||||
isNewRecord={isNewRecord}
|
||||
hasImportContent={hasImportContent}
|
||||
gridTemplateColumns={columnGridTemplate}
|
||||
shouldAutoFocusName={column.id === columnIdToFocus}
|
||||
onUpdateColumn={(changes) => onUpdateColumn(column, changes)}
|
||||
onRemoveColumn={() => onRemoveColumn(column)}
|
||||
@@ -318,6 +348,7 @@ export const ColumnManagement = ({
|
||||
isNewRecord={isNewRecord}
|
||||
hasForeignKeys={checkIfHaveForeignKeys(column)}
|
||||
hasImportContent={hasImportContent}
|
||||
gridTemplateColumns={columnGridTemplate}
|
||||
shouldAutoFocusName={column.id === columnIdToFocus}
|
||||
onUpdateColumn={(changes) => onUpdateColumn(column, changes)}
|
||||
onRemoveColumn={() => onRemoveColumn(column)}
|
||||
|
||||
@@ -40,11 +40,11 @@ We've improved the column Type field so that it supports your [custom types](htt
|
||||
|
||||

|
||||
|
||||
### Is Unique
|
||||
### Is unique
|
||||
|
||||
We've made it simple to add a unique constraint when creating or editing a table.
|
||||
|
||||

|
||||

|
||||
|
||||
### Edit columns
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ test.describe('column editor complex types', () => {
|
||||
'Type dropdown should show the bare base type, not the raw _int4 pg format'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
columnEditor.getByRole('checkbox', { name: 'Define as Array' }),
|
||||
'Define as Array should be checked for an existing array column'
|
||||
columnEditor.getByRole('checkbox', { name: 'Define as array' }),
|
||||
'Define as array should be checked for an existing array column'
|
||||
).toBeChecked()
|
||||
|
||||
// Make an unrelated change and save - this must not corrupt the column's type.
|
||||
|
||||
@@ -263,10 +263,10 @@ testRunner('table editor', () => {
|
||||
await page.getByRole('button', { name: 'New table', exact: true }).click()
|
||||
await page.getByTestId('table-name-input').fill(tableNameEnum)
|
||||
await page.getByTestId('created_at-extra-options').click()
|
||||
await page.getByText('Is Nullable').click()
|
||||
await page.getByText('Is nullable').click()
|
||||
await page.getByTestId('created_at-extra-options').click()
|
||||
await page.getByRole('button', { name: 'Add column' }).click()
|
||||
await page.getByLabel('Column name').nth(2).fill(columnNameEnum)
|
||||
await page.getByLabel('Column name').nth(3).fill(columnNameEnum)
|
||||
await page.getByRole('combobox').filter({ hasText: 'Choose a column type...' }).click()
|
||||
await page.getByPlaceholder('Search types...').fill(enum_name)
|
||||
// wait for response, then click
|
||||
@@ -357,7 +357,7 @@ testRunner('table editor', () => {
|
||||
.click()
|
||||
await page.getByRole('menuitem', { name: 'Edit table' }).click()
|
||||
await page.getByTestId('table-name-input').fill(tableNameUpdated)
|
||||
await page.getByLabel('Column name').nth(2).fill(columnNameUpdated)
|
||||
await page.getByLabel('Column name').nth(3).fill(columnNameUpdated)
|
||||
const updateTablePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=column-update', {
|
||||
method: 'POST',
|
||||
})
|
||||
@@ -948,7 +948,7 @@ testRunner('table editor', () => {
|
||||
|
||||
// Add boolean column
|
||||
await page.getByRole('button', { name: 'Add column' }).click()
|
||||
await page.getByLabel('Column name').nth(3).fill(boolColName)
|
||||
await page.getByLabel('Column name').nth(4).fill(boolColName)
|
||||
await page.getByText('Choose a column type...').click()
|
||||
await page.getByPlaceholder('Search types...').fill('bool')
|
||||
await page.getByRole('option', { name: 'bool' }).first().click()
|
||||
@@ -1064,7 +1064,7 @@ testRunner('table editor', () => {
|
||||
|
||||
// Add nullable boolean column
|
||||
await page.getByRole('button', { name: 'Add column' }).click()
|
||||
await page.getByLabel('Column name').nth(3).fill(boolColName)
|
||||
await page.getByLabel('Column name').nth(4).fill(boolColName)
|
||||
await page.getByText('Choose a column type...').click()
|
||||
await page.getByPlaceholder('Search types...').fill('bool')
|
||||
await page.getByRole('option', { name: 'bool' }).first().click()
|
||||
@@ -1985,14 +1985,14 @@ testRunner('table editor', () => {
|
||||
await page.getByRole('button', { name: 'New table' }).click()
|
||||
await page.getByLabel('Name', { exact: true }).fill(tableName)
|
||||
await page.getByRole('button', { name: 'Add column' }).click()
|
||||
await page.getByLabel('Column name').nth(2).fill('pw_column')
|
||||
await page.getByLabel('Column name').nth(3).fill('pw_column')
|
||||
await page.getByRole('combobox').filter({ hasText: 'Choose a column type...' }).click()
|
||||
await page.getByRole('option').filter({ hasText: 'int8' }).click()
|
||||
await page.getByLabel('Column default value').nth(2).fill('invalid')
|
||||
await page.getByLabel('Column default value').nth(3).fill('invalid')
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.getByText('invalid input syntax')).toBeVisible()
|
||||
await page.getByLabel('Column default value').nth(2).fill('10')
|
||||
await page.getByLabel('Column default value').nth(3).fill('10')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.getByText(`Table ${tableName} is good to go!`)).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: `View ${tableName}`, exact: true })).toBeVisible()
|
||||
|
||||
Reference in new issue
Block a user