Files
supabase/apps/studio/components/grid/utils/gridColumns.tsx
T
Joshen Lim a1abc2d00f Refactor table editor logic for handling null and undefined values (#44331)
## Context

Resolves https://github.com/supabase/supabase/issues/43548

There's currently an issue with the Table Editor where if you have, for
example, a nullable `text` column with a default value, inserting a new
row and selecting "Set to NULL" doesn't do anything, and saving will
insert the row with the default value
<img width="700" height="258" alt="image"
src="https://github.com/user-attachments/assets/6a284ebb-c346-40a6-9a30-793118844084"
/>

This stems from a legacy logic in the Table Editor whereby we treat
`null` values as "no input" - which is incorrect as `null` values are
also valid values. So the PR here changes a few things to resolve this
properly:

## Changes involved
Main fix: 
- `undefined` will be the "no input" value instead, and it'll be the
default value when generating the row object for inserting a new row
- `NULL` or even empty string like `''` will be treated as they are
(valid inputs)

Secondary adjustments:
- (Queue operations) Queueing an insert with no value but default value
is NULL, will show the placeholder as `DEFAULT` instead of `NULL` for
better accuracy in representation
<img width="892" height="96" alt="image"
src="https://github.com/user-attachments/assets/02cf86bf-c17b-4e25-9a8f-17960b1d2575"
/>
- Added a `Set to Default` CTA here, but will only show up if adding a
new row or updating a queued insert row operation, which will set the
value of the input field back to `undefined` for PG to handle it as the
default value
<img width="734" height="208" alt="image"
src="https://github.com/user-attachments/assets/23887c0c-533e-4494-acbe-61309ff5d7c5"
/>


## To test
Verify within the Table Editor (along with queue operation feature
preview)
- For inserting a new row, setting value to NULL and setting value to
Default works
- For updating a row, setting value to NULL works
2026-03-30 23:33:53 +08:00

296 lines
8.7 KiB
TypeScript

import { CalculatedColumn, RenderCellProps } from 'react-data-grid'
import { DefaultValue } from '../components/common/DefaultValue'
import { NullValue } from '../components/common/NullValue'
import { BooleanEditor } from '../components/editor/BooleanEditor'
import { DateTimeEditor } from '../components/editor/DateTimeEditor'
import { JsonEditor } from '../components/editor/JsonEditor'
import { NumberEditor } from '../components/editor/NumberEditor'
import { SelectEditor } from '../components/editor/SelectEditor'
import { TextEditor } from '../components/editor/TextEditor'
import { TimeEditor, TimeWithTimezoneEditor } from '../components/editor/TimeEditor'
import { BinaryFormatter } from '../components/formatter/BinaryFormatter'
import { BooleanFormatter } from '../components/formatter/BooleanFormatter'
import { DefaultFormatter } from '../components/formatter/DefaultFormatter'
import { ForeignKeyFormatter } from '../components/formatter/ForeignKeyFormatter'
import { JsonFormatter } from '../components/formatter/JsonFormatter'
import { AddColumn } from '../components/grid/AddColumn'
import { ColumnHeader } from '../components/grid/ColumnHeader'
import { SelectColumn } from '../components/grid/SelectColumn'
import {
isPendingAddRow,
type ColumnType,
type SupaColumn,
type SupaRow,
type SupaTable,
} from '../types'
import {
isArrayColumn,
isBinaryColumn,
isBoolColumn,
isCiTextColumn,
isDateColumn,
isDateTimeColumn,
isEnumColumn,
isForeignKeyColumn,
isJsonColumn,
isNumericalColumn,
isTextColumn,
isTimeColumn,
} from './types'
import { COLUMN_MIN_WIDTH } from '@/components/grid/constants'
export const ESTIMATED_CHARACTER_PIXEL_WIDTH = 9
export function getGridColumns(
table: SupaTable,
options?: {
tableId?: number
editable?: boolean
defaultWidth?: string | number
onAddColumn?: () => void
onExpandJSONEditor: (column: string, row: SupaRow) => void
onExpandTextEditor: (column: string, row: SupaRow) => void
}
): any[] {
const columns = table.columns.map((x, idx) => {
const columnType = getColumnType(x)
const columnDefaultWidth = getColumnDefaultWidth(x)
const columnWidthBasedOnName =
(x.name.length + x.format.length) * ESTIMATED_CHARACTER_PIXEL_WIDTH
const columnWidth = options?.defaultWidth
? options.defaultWidth
: columnDefaultWidth < columnWidthBasedOnName
? columnWidthBasedOnName
: columnDefaultWidth
const columnDefinition: CalculatedColumn<SupaRow> = {
key: x.name,
name: x.name,
idx: idx + 1,
resizable: true,
sortable: true,
width: columnWidth,
minWidth: COLUMN_MIN_WIDTH,
frozen: false,
renderHeaderCell: (props) => (
<ColumnHeader
{...props}
columnType={columnType}
isPrimaryKey={x.isPrimaryKey}
isEncrypted={x.isEncrypted}
format={x.format}
foreignKey={x.foreignKey}
comment={x.comment}
/>
),
renderEditCell: options
? getCellEditor(
x,
columnType,
options?.editable ?? false,
options.onExpandJSONEditor,
options.onExpandTextEditor
)
: undefined,
renderCell: getCellRenderer(x, columnType, {
tableId: options?.tableId,
}),
// [Next 18 Refactor] Double check if this is correct
parent: undefined,
level: 0,
maxWidth: undefined,
draggable: false,
}
return columnDefinition
})
const gridColumns = [SelectColumn, ...columns]
if (options?.onAddColumn) {
gridColumns.push(AddColumn)
}
return gridColumns
}
function getCellEditor(
columnDefinition: SupaColumn,
columnType: ColumnType,
isEditable: boolean,
onExpandJSONEditor: (column: string, row: any) => void,
onExpandTextEditor: (column: string, row: any) => void
) {
if (!isEditable) {
if (['array', 'json'].includes(columnType)) {
// eslint-disable-next-line react/display-name
return (p: any) => (
<JsonEditor {...p} isEditable={isEditable} onExpandEditor={onExpandJSONEditor} />
)
} else if (!['number', 'boolean'].includes(columnType)) {
// eslint-disable-next-line react/display-name
return (p: any) => (
<TextEditor {...p} isEditable={isEditable} onExpandEditor={onExpandTextEditor} />
)
} else {
return
}
}
if (columnDefinition.isPrimaryKey || !columnDefinition.isUpdatable) {
return
}
switch (columnType) {
case 'boolean': {
// eslint-disable-next-line react/display-name
return (p: any) => <BooleanEditor {...p} isNullable={columnDefinition.isNullable} />
}
case 'date': {
return DateTimeEditor('date', columnDefinition.isNullable || false)
}
case 'datetime': {
return columnDefinition.format.endsWith('z')
? DateTimeEditor('datetimetz', columnDefinition.isNullable || false)
: DateTimeEditor('datetime', columnDefinition.isNullable || false)
}
case 'time': {
return columnDefinition.format.endsWith('z') ? TimeWithTimezoneEditor : TimeEditor
}
case 'enum': {
const options = columnDefinition.enum!.map((x) => {
return { label: x, value: x }
})
// eslint-disable-next-line react/display-name
return (p: any) => (
<SelectEditor {...p} options={options} isNullable={columnDefinition.isNullable} />
)
}
case 'array':
case 'json': {
// eslint-disable-next-line react/display-name
return (p: any) => <JsonEditor {...p} onExpandEditor={onExpandJSONEditor} />
}
case 'number': {
return NumberEditor
}
case 'citext':
case 'text': {
// eslint-disable-next-line react/display-name
return (p: any) => (
<TextEditor
{...p}
isEditable={isEditable}
isNullable={columnDefinition.isNullable}
onExpandEditor={onExpandTextEditor}
/>
)
}
default: {
return undefined
}
}
}
function withPendingAddPlaceholders(
Formatter: React.ComponentType<RenderCellProps<SupaRow, unknown>>,
columnDef: SupaColumn
) {
return function PendingAwareFormatter(props: RenderCellProps<SupaRow, unknown>) {
const value = props.row[props.column.key]
if (isPendingAddRow(props.row) && (value === undefined || value === null || value === '')) {
if (value === null) {
return <NullValue />
}
if (columnDef.defaultValue !== undefined || columnDef.isIdentity || columnDef.isGeneratable) {
return <DefaultValue />
}
}
return <Formatter {...props} />
}
}
function getCellRenderer(
columnDef: SupaColumn,
columnType: ColumnType,
metadata: { tableId?: number }
) {
let formatter: React.ComponentType<RenderCellProps<SupaRow, unknown>>
switch (columnType) {
case 'boolean': {
formatter = BooleanFormatter
break
}
case 'foreign_key': {
if (!columnDef.isUpdatable) {
formatter = DefaultFormatter
} else {
// eslint-disable-next-line react/display-name
formatter = (p: any) => <ForeignKeyFormatter {...p} tableId={metadata.tableId} />
}
break
}
case 'binary': {
formatter = BinaryFormatter
break
}
case 'json': {
formatter = JsonFormatter
break
}
default: {
formatter = DefaultFormatter
}
}
// Wrap all formatters to handle pending add row placeholders
return withPendingAddPlaceholders(formatter, columnDef)
}
function getColumnType(columnDef: SupaColumn): ColumnType {
if (isForeignKeyColumn(columnDef)) {
return 'foreign_key'
} else if (isNumericalColumn(columnDef.dataType)) {
return 'number'
} else if (isArrayColumn(columnDef.dataType)) {
return 'array'
} else if (isJsonColumn(columnDef.dataType)) {
return 'json'
} else if (isTextColumn(columnDef.dataType)) {
return 'text'
} else if (isCiTextColumn(columnDef.format)) {
return 'citext'
} else if (isDateColumn(columnDef.format)) {
return 'date'
} else if (isTimeColumn(columnDef.format)) {
return 'time'
} else if (isDateTimeColumn(columnDef.format)) {
return 'datetime'
} else if (isBoolColumn(columnDef.dataType)) {
return 'boolean'
} else if (isEnumColumn(columnDef.dataType)) {
return 'enum'
} else if (isBinaryColumn(columnDef.dataType)) {
return 'binary'
} else return 'unknown'
}
export function getColumnDefaultWidth(columnDef: SupaColumn): number {
if (isNumericalColumn(columnDef.dataType)) {
return 120
} else if (
isDateTimeColumn(columnDef.format) ||
isDateColumn(columnDef.format) ||
isTimeColumn(columnDef.format)
) {
return 150
} else if (isBoolColumn(columnDef.dataType)) {
return 120
} else if (isEnumColumn(columnDef.dataType)) {
return 150
} else return 250
}