Refactor ColumnEditor to use latest UI components + fix some UI oddities (#44214)

## Context

Refactors the Table Editor's `ColumnEditor` to use the latest UI
components, and fix some UI oddities along the way

## Bug fixes

- Fix header text vertical alignment
  - Before:
<img width="325" height="59" alt="image"
src="https://github.com/user-attachments/assets/e4bc07d4-2630-4a86-a87c-4bbbf94e2f52"
/>
  - After:
<img width="351" height="74" alt="image"
src="https://github.com/user-attachments/assets/d0a0a246-59b6-4d19-8674-8cc5eb33772c"
/>
- Fix closing a toast would close the panel as well
- Can verify by creating a new column, then hitting save without
entering anything. Will trigger some error toasts and closing them will
close the panel too
- Fix tooltips on "is nullable" and "is unique" showing up irregardless
if "is primary key" is toggled on or off
This commit is contained in:
Joshen Lim
2026-03-26 14:36:21 +08:00
committed by GitHub
parent 0c5f64fcba
commit 3debd400a6
3 changed files with 304 additions and 234 deletions

View File

@@ -18,17 +18,25 @@ import { isEmpty, noop } from 'lodash'
import { ExternalLink, Plus } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import type { Dictionary } from 'types'
import {
Button,
Checkbox,
Input,
Checkbox_Shadcn_,
cn,
DialogSectionSeparator,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
SidePanel,
Toggle,
Switch,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { ActionBar } from '../ActionBar'
import type { ForeignKey } from '../ForeignKeySelector/ForeignKeySelector.types'
@@ -67,7 +75,7 @@ export interface ColumnEditorProps {
existingForeignKeyRelations: ForeignKeyConstraint[]
createMore?: boolean
},
resolve: any
resolve: () => void
) => void
updateEditorDirty: () => void
}
@@ -83,7 +91,7 @@ export const ColumnEditor = ({
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const [errors, setErrors] = useState<Dictionary<any>>({})
const [errors, setErrors] = useState<{ [key: string]: string }>({})
const [columnFields, setColumnFields] = useState<ColumnField>()
const [fkRelations, setFkRelations] = useState<ForeignKey[]>([])
const [createMore, setCreateMore] = useState(false)
@@ -135,6 +143,7 @@ export const ColumnEditor = ({
setColumnFields(columnFields)
setFkRelations(formatForeignKeys(foreignKeys))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible])
if (!columnFields) return null
@@ -195,7 +204,7 @@ export const ColumnEditor = ({
existingForeignKeyRelations: foreignKeys,
createMore,
}
saveChanges(payload, isNewRecord, configuration, (err?: any) => {
saveChanges(payload, isNewRecord, configuration, (err?: string) => {
resolve()
if (!err && createMore && isNewRecord) {
const freshColumnFields = generateColumnField({
@@ -214,234 +223,295 @@ export const ColumnEditor = ({
}
return (
<SidePanel
size="xlarge"
key="ColumnEditor"
visible={visible}
// @ts-ignore
onConfirm={(resolve: () => void) => onSaveChanges(resolve)}
// @ts-ignore
header={<HeaderTitle table={selectedTable} column={column} />}
onCancel={closePanel}
customFooter={
<ActionBar
backButtonLabel="Cancel"
applyButtonLabel="Save"
closePanel={closePanel}
applyFunction={(resolve: () => void) => onSaveChanges(resolve)}
visible={visible}
>
{isNewRecord && (
<div className="flex items-center gap-x-2">
<Toggle
size="tiny"
checked={createMore}
onChange={() => setCreateMore(!createMore)}
/>
<label
className="text-foreground-light text-sm cursor-pointer select-none"
onClick={() => setCreateMore(!createMore)}
<Sheet key="ColumnEditor" open={visible} onOpenChange={(open) => !open && closePanel()}>
<SheetContent
size="lg"
aria-describedby={undefined}
className="flex flex-col gap-0"
onInteractOutside={(e) => {
// Prevent sheet from closing when interacting with toasts
const target = e.target as HTMLElement
if (target?.closest('[data-sonner-toast]')) e.preventDefault()
}}
>
<SheetHeader>
<SheetTitle>
<HeaderTitle table={selectedTable} column={column} />
</SheetTitle>
</SheetHeader>
<SheetSection className="overflow-auto flex-grow p-0">
<FormSection
header={<FormSectionLabel className="lg:!col-span-4">General</FormSectionLabel>}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<FormItemLayout
isReactForm={false}
id="name"
className={cn(errors.name && '[&>*>label]:text-destructive')}
label="Name"
description={
<>
Recommended to use lowercase and use an underscore to separate words e.g.{' '}
<code className="text-code-inline">column_name</code>
</>
}
>
Create more
</label>
</div>
)}
</ActionBar>
}
>
<FormSection header={<FormSectionLabel className="lg:!col-span-4">General</FormSectionLabel>}>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Input
id="name"
label="Name"
type="text"
descriptionText="Recommended to use lowercase and use an underscore to separate words e.g. column_name"
placeholder="column_name"
error={errors.name}
value={columnFields?.name ?? ''}
onChange={(event: any) => onUpdateField({ name: event.target.value })}
/>
<Input
id="description"
label="Description"
labelOptional="Optional"
type="text"
value={columnFields?.comment ?? ''}
onChange={(event: any) => onUpdateField({ comment: event.target.value })}
/>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={
<FormSectionLabel
className="lg:!col-span-4"
description={
<div className="space-y-2">
<Button asChild type="default" size="tiny" icon={<Plus strokeWidth={2} />}>
<Link href={`/project/${ref}/database/types`} target="_blank" rel="noreferrer">
Create enum types
</Link>
</Button>
<Button
asChild
type="default"
size="tiny"
icon={<ExternalLink size={14} strokeWidth={2} />}
>
<Link
href={`${DOCS_URL}/guides/database/tables#data-types`}
target="_blank"
rel="noreferrer"
>
About data types
</Link>
</Button>
</div>
<Input
id="name"
type="text"
placeholder="column_name"
value={columnFields?.name ?? ''}
onChange={(event) => onUpdateField({ name: event.target.value })}
/>
{errors.name && <p className="mt-2 text-destructive">{errors.name}</p>}
</FormItemLayout>
<FormItemLayout
isReactForm={false}
id="description"
label="Description"
labelOptional="Optional"
>
<Input
id="description"
type="text"
value={columnFields?.comment ?? ''}
onChange={(event) => onUpdateField({ comment: event.target.value })}
/>
</FormItemLayout>
</FormSectionContent>
</FormSection>
<DialogSectionSeparator />
<FormSection
header={
<FormSectionLabel
className="lg:!col-span-4"
description={
<div className="space-y-2">
<Button asChild type="default" icon={<Plus />}>
<Link
target="_blank"
rel="noreferrer"
href={`/project/${ref}/database/types`}
>
Create enum types
</Link>
</Button>
<Button asChild type="default" icon={<ExternalLink />}>
<Link
target="_blank"
rel="noreferrer"
href={`${DOCS_URL}/guides/database/tables#data-types`}
>
About data types
</Link>
</Button>
</div>
}
>
Data Type
</FormSectionLabel>
}
>
Data Type
</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<ColumnType
showRecommendation
value={columnFields?.format ?? ''}
layout="vertical"
enumTypes={enumTypes}
error={errors.format}
description={
lockColumnType ? 'Column type cannot be changed as it has a foreign key relation' : ''
}
disabled={lockColumnType}
onOptionSelect={(format: string) => onUpdateField({ format, defaultValue: null })}
/>
{columnFields.foreignKey === undefined && (
<div className="space-y-4">
{columnFields.format.includes('int') && (
<div className="w-full">
<Checkbox
label="Is Identity"
description="Automatically assign a sequential unique number to the column"
checked={columnFields.isIdentity}
onChange={() => {
const isIdentity = !columnFields.isIdentity
const isArray = isIdentity ? false : columnFields.isArray
onUpdateField({ isIdentity, isArray })
}}
/>
<FormSectionContent loading={false} className="lg:!col-span-8">
<ColumnType
showRecommendation
value={columnFields?.format ?? ''}
layout="vertical"
enumTypes={enumTypes}
error={errors.format}
description={
lockColumnType
? 'Column type cannot be changed as it has a foreign key relation'
: ''
}
disabled={lockColumnType}
onOptionSelect={(format: string) => onUpdateField({ format, defaultValue: null })}
/>
{columnFields.foreignKey === undefined && (
<div className="space-y-4">
{columnFields.format.includes('int') && (
<FormItemLayout
isReactForm={false}
layout="flex"
label="Is Identity"
id="isIdentity"
description="Automatically assign a sequential unique number to the column"
>
<Checkbox_Shadcn_
id="isIdentity"
checked={columnFields.isIdentity}
onCheckedChange={() => {
const isIdentity = !columnFields.isIdentity
const isArray = isIdentity ? false : columnFields.isArray
onUpdateField({ isIdentity, isArray })
}}
/>
</FormItemLayout>
)}
{!columnFields.isPrimaryKey && (
<FormItemLayout
isReactForm={false}
layout="flex"
id="isArray"
label="Define as Array"
description="Allow column to be defined as variable-length multidimensional arrays"
>
<Checkbox_Shadcn_
id="isArray"
checked={columnFields.isArray}
onCheckedChange={() => {
const isArray = !columnFields.isArray
const isIdentity = isArray ? false : columnFields.isIdentity
onUpdateField({ isArray, isIdentity })
}}
/>
</FormItemLayout>
)}
</div>
)}
{!columnFields.isPrimaryKey && (
<div className="w-full">
<Checkbox
label="Define as Array"
description="Allow column to be defined as variable-length multidimensional arrays"
checked={columnFields.isArray}
onChange={() => {
const isArray = !columnFields.isArray
const isIdentity = isArray ? false : columnFields.isIdentity
onUpdateField({ isArray, isIdentity })
}}
/>
</div>
)}
</div>
)}
<ColumnDefaultValue
columnFields={columnFields}
enumTypes={enumTypes}
onUpdateField={onUpdateField}
/>
</FormSectionContent>
</FormSection>
<ColumnDefaultValue
columnFields={columnFields}
enumTypes={enumTypes}
onUpdateField={onUpdateField}
/>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<SidePanel.Separator />
<FormSection
header={<FormSectionLabel className="lg:!col-span-4">Foreign Keys</FormSectionLabel>}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<ColumnForeignKey
tableId={selectedTable.id}
column={columnFields}
relations={fkRelations}
<FormSection
header={<FormSectionLabel className="lg:!col-span-4">Foreign Keys</FormSectionLabel>}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<ColumnForeignKey
tableId={selectedTable.id}
column={columnFields}
relations={fkRelations}
closePanel={closePanel}
onUpdateColumnType={(format: string) => {
if (format[0] === '_') {
onUpdateField({ format: format.slice(1), isArray: true, isIdentity: false })
} else {
onUpdateField({ format })
}
}}
onUpdateFkRelations={setFkRelations}
/>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={<FormSectionLabel className="lg:!col-span-4">Constraints</FormSectionLabel>}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<FormItemLayout
isReactForm={false}
layout="flex"
id="isPrimaryKey"
label="Is Primary Key"
description="A primary key indicates that a column or group of columns can be used as a unique identifier for rows in the table"
>
<Switch
id="isPrimaryKey"
checked={columnFields?.isPrimaryKey ?? false}
onCheckedChange={() =>
onUpdateField({
isPrimaryKey: !columnFields?.isPrimaryKey,
isUnique: false,
isNullable: false,
})
}
/>
</FormItemLayout>
<Tooltip>
<TooltipTrigger>
<FormItemLayout
isReactForm={false}
layout="flex"
id="isNullable"
label="Allow Nullable"
description="Allow the column to assume a NULL value if no value is provided"
>
<Switch
id="isNullable"
disabled={columnFields.isPrimaryKey}
checked={columnFields.isNullable}
onCheckedChange={() =>
onUpdateField({ isNullable: !columnFields.isNullable })
}
/>
</FormItemLayout>
</TooltipTrigger>
{columnFields.isPrimaryKey && (
<TooltipContent side="left" align="start">
Column is a primary key and hence cannot be NULL
</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger>
<FormItemLayout
isReactForm={false}
layout="flex"
id="isUnique"
label="Is Unique"
description="Enforce values in the column to be unique across rows"
>
<Switch
id="isUnique"
disabled={columnFields.isPrimaryKey}
checked={columnFields.isUnique}
onCheckedChange={() => onUpdateField({ isUnique: !columnFields.isUnique })}
/>
</FormItemLayout>
</TooltipTrigger>
{columnFields.isPrimaryKey && (
<TooltipContent side="left" align="start">
Column is a primary key and hence already unique
</TooltipContent>
)}
</Tooltip>
<FormItemLayout isReactForm={false} label="CHECK constraint" labelOptional="Optional">
<Input
type="text"
placeholder={placeholder}
value={columnFields?.check ?? ''}
onChange={(event) => onUpdateField({ check: event.target.value })}
className="[&_input]:font-mono"
/>
</FormItemLayout>
</FormSectionContent>
</FormSection>
</SheetSection>
<SheetFooter className="!justify-between [&>div]:p-0 [&>div]:border-t-0">
<ActionBar
backButtonLabel="Cancel"
applyButtonLabel="Save"
closePanel={closePanel}
onUpdateColumnType={(format: string) => {
if (format[0] === '_') {
onUpdateField({ format: format.slice(1), isArray: true, isIdentity: false })
} else {
onUpdateField({ format })
}
}}
onUpdateFkRelations={setFkRelations}
/>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={<FormSectionLabel className="lg:!col-span-4">Constraints</FormSectionLabel>}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Toggle
label="Is Primary Key"
descriptionText="A primary key indicates that a column or group of columns can be used as a unique identifier for rows in the table"
checked={columnFields?.isPrimaryKey ?? false}
onChange={() =>
onUpdateField({
isPrimaryKey: !columnFields?.isPrimaryKey,
isUnique: false,
isNullable: false,
})
}
/>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Toggle
label="Allow Nullable"
disabled={columnFields.isPrimaryKey}
descriptionText="Allow the column to assume a NULL value if no value is provided"
checked={columnFields.isNullable}
onChange={() => onUpdateField({ isNullable: !columnFields.isNullable })}
/>
applyFunction={(resolve: () => void) => onSaveChanges(resolve)}
visible={visible}
>
{isNewRecord && (
<div className="flex items-center gap-x-2">
<Switch checked={createMore} onCheckedChange={() => setCreateMore(!createMore)} />
<label
className="text-foreground-light text-sm cursor-pointer select-none"
onClick={() => setCreateMore(!createMore)}
>
Create more
</label>
</div>
</TooltipTrigger>
<TooltipContent side="left" align="start">
Column is a primary key and hence cannot be NULL
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Toggle
label="Is Unique"
disabled={columnFields.isPrimaryKey}
descriptionText="Enforce values in the column to be unique across rows"
checked={columnFields.isUnique}
onChange={() => onUpdateField({ isUnique: !columnFields.isUnique })}
/>
</div>
</TooltipTrigger>
<TooltipContent side="left" align="start">
Column is a primary key and hence already unique
</TooltipContent>
</Tooltip>
<Input
label="CHECK Constraint"
labelOptional="Optional"
placeholder={placeholder}
type="text"
value={columnFields?.check ?? ''}
onChange={(event: any) => onUpdateField({ check: event.target.value })}
className="[&_input]:font-mono"
/>
</FormSectionContent>
</FormSection>
</SidePanel>
)}
</ActionBar>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View File

@@ -2,7 +2,7 @@ import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta'
interface Props {
table: PostgresTable
column: PostgresColumn
column?: PostgresColumn
}
export const HeaderTitle = ({ table, column }: Props) => {

View File

@@ -7,21 +7,21 @@ interface HeaderTitleProps {
export const HeaderTitle = ({ schema, table, isDuplicating }: HeaderTitleProps) => {
if (!table) {
return (
<>
<span>
Create a new table under <code className="text-code-inline !text-sm">{schema}</code>
</>
</span>
)
}
if (isDuplicating) {
return (
<>
<span>
Duplicate table <code className="text-code-inline !text-sm">{table?.name}</code>
</>
</span>
)
}
return (
<>
<span>
Update table <code className="text-code-inline !text-sm">{table?.name}</code>
</>
</span>
)
}