Skip to content
17 changes: 16 additions & 1 deletion apps/sim/app/api/table/[tableId]/columns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
deleteColumn,
renameColumn,
updateColumnConstraints,
updateColumnOptions,
updateColumnType,
} from '@/lib/table'
import { accessError, checkAccess, normalizeColumn, rootErrorMessage } from '@/app/api/table/utils'
Expand Down Expand Up @@ -118,7 +119,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu

if (updates.type) {
updatedTable = await updateColumnType(
{ tableId, columnName: updates.name ?? validated.columnName, newType: updates.type },
{
tableId,
columnName: updates.name ?? validated.columnName,
newType: updates.type,
// Applied inside updateColumnType's transaction so a combined
// type+options change is atomic.
...(updates.options !== undefined ? { options: updates.options } : {}),
},
requestId
)
}
Expand All @@ -135,6 +143,13 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
)
}

if (updates.options !== undefined && !updates.type) {
updatedTable = await updateColumnOptions(
{ tableId, columnName: updates.name ?? validated.columnName, options: updates.options },
requestId
)
}
Comment thread
waleedlatif1 marked this conversation as resolved.

if (!updatedTable) {
return NextResponse.json({ error: 'No updates specified' }, { status: 400 })
}
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/table/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
type: col.type,
required: col.required ?? false,
unique: col.unique ?? false,
...(col.options !== undefined ? { options: col.options } : {}),
...(col.workflowGroupId ? { workflowGroupId: col.workflowGroupId } : {}),
}
}
17 changes: 16 additions & 1 deletion apps/sim/app/api/v1/tables/[tableId]/columns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
deleteColumn,
renameColumn,
updateColumnConstraints,
updateColumnOptions,
updateColumnType,
} from '@/lib/table'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
Expand Down Expand Up @@ -140,7 +141,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu

if (updates.type) {
updatedTable = await updateColumnType(
{ tableId, columnName: updates.name ?? validated.columnName, newType: updates.type },
{
tableId,
columnName: updates.name ?? validated.columnName,
newType: updates.type,
// Applied inside updateColumnType's transaction so a combined
// type+options change is atomic.
...(updates.options !== undefined ? { options: updates.options } : {}),
},
requestId
)
}
Expand All @@ -157,6 +165,13 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
)
}

if (updates.options !== undefined && !updates.type) {
updatedTable = await updateColumnOptions(
{ tableId, columnName: updates.name ?? validated.columnName, options: updates.options },
requestId
)
}

if (!updatedTable) {
return NextResponse.json({ error: 'No updates specified' }, { status: 400 })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState } from 'react'
import { toError } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import {
Button,
ChipCombobox,
Expand All @@ -11,7 +12,7 @@ import {
Switch,
toast,
} from '@/components/emcn'
import { X } from '@/components/emcn/icons'
import { Plus, X } from '@/components/emcn/icons'
import { findValidationIssue, isValidationError } from '@/lib/api/client/errors'
import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition } from '@/lib/table'
Expand Down Expand Up @@ -79,6 +80,79 @@ function configKey(config: ColumnConfig): string {
return config.mode === 'edit' ? `edit:${config.columnName}` : `create:${config.proposedName}`
}

/** Local editing row for one select option — `id` keys the input across edits. */
interface OptionDraft {
id: string
value: string
}

function toOptionDrafts(options: string[]): OptionDraft[] {
return options.map((value) => ({ id: generateShortId(), value }))
}

/** Trims drafts, drops empties, and de-dupes case-insensitively (first wins). */
function cleanOptionDrafts(drafts: OptionDraft[]): string[] {
const seen = new Set<string>()
const cleaned: string[] = []
for (const draft of drafts) {
const value = draft.value.trim()
if (!value) continue
const normalized = value.toLowerCase()
if (seen.has(normalized)) continue
seen.add(normalized)
cleaned.push(value)
}
return cleaned
}

interface SelectOptionsFieldProps {
options: OptionDraft[]
onChange: (options: OptionDraft[]) => void
}

/** Editable list of a select column's predefined options. */
function SelectOptionsField({ options, onChange }: SelectOptionsFieldProps) {
return (
<div className='flex flex-col gap-[9.5px]'>
<Label>Options</Label>
{options.map((option, index) => (
<div key={option.id} className='flex items-center gap-1.5'>
<ChipInput
value={option.value}
onChange={(e) =>
onChange(
options.map((o) => (o.id === option.id ? { ...o, value: e.target.value } : o))
)
}
placeholder={`Option ${index + 1}`}
spellCheck={false}
autoComplete='off'
className='min-w-0 flex-1'
/>
<Button
variant='ghost'
size='sm'
onClick={() => onChange(options.filter((o) => o.id !== option.id))}
className='!p-1 size-7 shrink-0'
aria-label='Remove option'
>
<X className='size-[12px]' />
</Button>
</div>
))}
<Button
variant='default'
size='sm'
onClick={() => onChange([...options, { id: generateShortId(), value: '' }])}
className='self-start'
>
<Plus className='mr-1 size-[10px]' />
Add option
</Button>
</div>
)
}

interface ColumnConfigBodyProps extends Omit<ColumnConfigSidebarProps, 'config'> {
config: ColumnConfig
}
Expand All @@ -103,6 +177,9 @@ function ColumnConfigBody({
const [uniqueInput, setUniqueInput] = useState<boolean>(() =>
config.mode === 'edit' ? !!existingColumn?.unique : false
)
const [optionDrafts, setOptionDrafts] = useState<OptionDraft[]>(() =>
config.mode === 'edit' ? toOptionDrafts(existingColumn?.options ?? []) : []
)
const [showValidation, setShowValidation] = useState(false)
const [nameError, setNameError] = useState<string | null>(null)

Expand All @@ -115,12 +192,17 @@ function ColumnConfigBody({
return
}

const cleanedOptions = cleanOptionDrafts(optionDrafts)

try {
if (config.mode === 'create') {
await addColumn.mutateAsync({
name: trimmedName,
type: typeInput,
...(uniqueInput ? { unique: true } : {}),
...(typeInput === 'select' && cleanedOptions.length > 0
? { options: cleanedOptions }
: {}),
})
toast.success(`Added "${trimmedName}"`)
onClose()
Expand All @@ -132,11 +214,20 @@ function ColumnConfigBody({
const renamed = trimmedName !== (existingColumn?.name ?? config.columnName)
const typeChanged = !!existingColumn && existingColumn.type !== typeInput
const uniqueChanged = !!existingColumn && !!existingColumn.unique !== uniqueInput
const optionsChanged =
typeInput === 'select' &&
JSON.stringify(cleanedOptions) !== JSON.stringify(existingColumn?.options ?? [])

const updates: { name?: string; type?: ColumnDefinition['type']; unique?: boolean } = {
const updates: {
name?: string
type?: ColumnDefinition['type']
unique?: boolean
options?: string[]
} = {
...(renamed ? { name: trimmedName } : {}),
...(typeChanged ? { type: typeInput } : {}),
...(uniqueChanged ? { unique: uniqueInput } : {}),
...(optionsChanged ? { options: cleanedOptions } : {}),
}
if (Object.keys(updates).length === 0) {
onClose()
Expand Down Expand Up @@ -216,6 +307,13 @@ function ColumnConfigBody({
</>
)}

{typeInput === 'select' && (
<>
<FieldDivider />
<SelectOptionsField options={optionDrafts} onChange={setOptionDrafts} />
</>
)}

<FieldDivider />
<div className='flex flex-col gap-[9.5px]'>
<div className='flex items-center justify-between pl-0.5'>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type React from 'react'
import { CircleChevronDown, DollarSign, Percent, Phone, Star } from 'lucide-react'
import {
Calendar as CalendarIcon,
Link as LinkIcon,
Mail,
PlayOutline,
TypeBoolean,
TypeJson,
Expand All @@ -27,6 +30,13 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [
{ type: 'number', label: 'Number', icon: TypeNumber },
{ type: 'boolean', label: 'Boolean', icon: TypeBoolean },
{ type: 'date', label: 'Date', icon: CalendarIcon },
{ type: 'select', label: 'Select', icon: CircleChevronDown },
{ type: 'url', label: 'URL', icon: LinkIcon },
{ type: 'email', label: 'Email', icon: Mail },
{ type: 'phone', label: 'Phone', icon: Phone },
{ type: 'currency', label: 'Currency', icon: DollarSign },
{ type: 'percent', label: 'Percent', icon: Percent },
{ type: 'rating', label: 'Rating', icon: Star },
{ type: 'json', label: 'JSON', icon: TypeJson },
{ type: 'workflow', label: 'Workflow', icon: PlayOutline },
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { useParams } from 'next/navigation'
import {
Checkbox,
ChipCombobox,
ChipConfirmModal,
ChipModal,
ChipModalBody,
Expand All @@ -17,6 +18,7 @@ import {
Label,
} from '@/components/emcn'
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
import { getColumnStorageType } from '@/lib/table/constants'
import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables'
import { cleanCellValue, formatValueForInput } from '../../utils'

Expand Down Expand Up @@ -250,16 +252,65 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
)
}

if (column.type === 'select') {
const options = column.options ?? []
const current = typeof value === 'string' ? value : ''
// Match the stored value case-insensitively (same as the grid's tag
// rendering) so a casing variant selects its canonical option instead of
// appearing as a duplicate entry.
const matched = options.find((option) => option.toLowerCase() === current.toLowerCase())
const selectOptions = [
...options.map((option) => ({ label: option, value: option })),
...(current && matched === undefined ? [{ label: current, value: current }] : []),
]
return (
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
<ChipCombobox
options={selectOptions}
value={matched ?? current}
onChange={(v) => onChange(v)}
placeholder='Select option'
maxHeight={260}
/>
</ChipModalField>
)
}

if (column.type === 'email') {
return (
<ChipModalField
type='email'
title={title}
required={column.required}
hint={hint}
value={formatValueForInput(value, column.type)}
onChange={onChange}
placeholder={`Enter ${column.name}`}
/>
)
}

const inputType =
getColumnStorageType(column.type) === 'number'
? 'number'
: (FIELD_INPUT_TYPES[column.type] ?? 'text')

return (
<ChipModalField
type='input'
title={title}
required={column.required}
hint={hint}
inputType={column.type === 'number' ? 'number' : 'text'}
inputType={inputType}
value={formatValueForInput(value, column.type)}
onChange={onChange}
placeholder={`Enter ${column.name}`}
/>
)
}

/** Browser input types for rich string-backed columns (default: `text`). */
const FIELD_INPUT_TYPES: Partial<Record<ColumnDefinition['type'], 'url' | 'tel'>> = {
url: 'url',
phone: 'tel',
}
Loading
Loading