Skip to content
Open
31 changes: 27 additions & 4 deletions apps/sim/app/api/table/[tableId]/columns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import {
deleteColumn,
renameColumn,
updateColumnConstraints,
updateColumnOptions,
updateColumnType,
} from '@/lib/table'
import { columnMatchesRef } from '@/lib/table/column-keys'
import { accessError, checkAccess, normalizeColumn, rootErrorMessage } from '@/app/api/table/utils'

const logger = createLogger('TableColumnsAPI')
Expand Down Expand Up @@ -68,7 +70,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
msg.includes('already exists') ||
msg.includes('maximum column') ||
msg.includes('Invalid column') ||
msg.includes('exceeds maximum')
msg.includes('exceeds maximum') ||
msg.includes('option')
) {
return NextResponse.json({ error: msg }, { status: 400 })
}
Expand Down Expand Up @@ -116,9 +119,28 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
)
}

if (updates.type) {
// A payload that repeats the current type must not go through
// `updateColumnType` — it early-returns on an unchanged type and would drop
// any `options` alongside it. Only a real type change routes there; an
// unchanged type with options routes to the options-only update.
const currentColumn = table.schema.columns.find((c) =>
columnMatchesRef(c, validated.columnName)
)
const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type

if (typeChanging) {
updatedTable = await updateColumnType(
{ tableId, columnName: updates.name ?? validated.columnName, newType: updates.type },
{
tableId,
columnName: updates.name ?? validated.columnName,
newType: updates.type as NonNullable<typeof updates.type>,
...(updates.options !== undefined ? { options: updates.options } : {}),
},
requestId
)
} else if (updates.options !== undefined) {
updatedTable = await updateColumnOptions(
{ tableId, columnName: updates.name ?? validated.columnName, options: updates.options },
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Comment thread
TheodoreSpeaks marked this conversation as resolved.
requestId
)
}
Expand Down Expand Up @@ -162,7 +184,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
msg.includes('Invalid column') ||
msg.includes('exceeds maximum') ||
msg.includes('incompatible') ||
msg.includes('duplicate')
msg.includes('duplicate') ||
msg.includes('option')
) {
return NextResponse.json({ error: msg }, { 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 @@ -279,5 +279,6 @@ export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
required: col.required ?? false,
unique: col.unique ?? false,
...(col.workflowGroupId ? { workflowGroupId: col.workflowGroupId } : {}),
...(col.options ? { options: col.options } : {}),
}
}
28 changes: 25 additions & 3 deletions apps/sim/app/api/v1/tables/[tableId]/columns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import {
deleteColumn,
renameColumn,
updateColumnConstraints,
updateColumnOptions,
updateColumnType,
} from '@/lib/table'
import { columnMatchesRef } from '@/lib/table/column-keys'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
import {
checkRateLimit,
Expand Down Expand Up @@ -138,9 +140,28 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
)
}

if (updates.type) {
// A payload that repeats the current type must not go through
// `updateColumnType` — it early-returns on an unchanged type and would drop
// any `options` alongside it. Only a real type change routes there; an
// unchanged type with options routes to the options-only update.
const currentColumn = table.schema.columns.find((c) =>
columnMatchesRef(c, validated.columnName)
)
const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type

if (typeChanging) {
updatedTable = await updateColumnType(
{ tableId, columnName: updates.name ?? validated.columnName, newType: updates.type },
{
tableId,
columnName: updates.name ?? validated.columnName,
newType: updates.type as NonNullable<typeof updates.type>,
...(updates.options !== undefined ? { options: updates.options } : {}),
},
requestId
)
} else if (updates.options !== undefined) {
updatedTable = await updateColumnOptions(
{ tableId, columnName: updates.name ?? validated.columnName, options: updates.options },
requestId
)
}
Expand Down Expand Up @@ -195,7 +216,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
msg.includes('Invalid column') ||
msg.includes('exceeds maximum') ||
msg.includes('incompatible') ||
msg.includes('duplicate')
msg.includes('duplicate') ||
msg.includes('option')
) {
return NextResponse.json({ error: msg }, { status: 400 })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,24 @@ import { Button, ChipCombobox, ChipInput, cn, FieldDivider, Label, Switch, toast
import { X } from '@sim/emcn/icons'
import { toError } from '@sim/utils/errors'
import { findValidationIssue, isValidationError } from '@/lib/api/client/errors'
import type { ColumnDefinition } from '@/lib/table'
import type { ColumnDefinition, SelectOption } from '@/lib/table'
import {
FieldError,
RequiredLabel,
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields'
import { useAddTableColumn, useUpdateColumn } from '@/hooks/queries/tables'
import { SelectOptionsEditor } from '../select-field'
import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types'

/** Whether a column type carries an option set. */
function isSelectType(type: ColumnDefinition['type']): boolean {
return type === 'select' || type === 'multiselect'
}

function optionsEqual(a: SelectOption[], b: SelectOption[]): boolean {
return JSON.stringify(a) === JSON.stringify(b)
}

/**
* Discriminates the two flows the column-config sidebar handles. Workflow
* configuration is a separate component (`<WorkflowSidebar>`) so this surface
Expand Down Expand Up @@ -94,24 +104,47 @@ function ColumnConfigBody({
const [uniqueInput, setUniqueInput] = useState<boolean>(() =>
config.mode === 'edit' ? !!existingColumn?.unique : false
)
const [optionsInput, setOptionsInput] = useState<SelectOption[]>(() =>
config.mode === 'edit' ? (existingColumn?.options ?? []) : []
)
const [showValidation, setShowValidation] = useState(false)
const [nameError, setNameError] = useState<string | null>(null)
const [optionsError, setOptionsError] = useState<string | null>(null)

const saveDisabled = updateColumn.isPending || addColumn.isPending
const trimmedName = nameInput.trim()
const wantsOptions = isSelectType(typeInput)
const trimmedOptions = optionsInput.map((o) => ({ ...o, name: o.name.trim() }))

/** Client-side option validation mirroring the server rules; returns an error message or null. */
function validateOptions(): string | null {
if (!wantsOptions) return null
if (trimmedOptions.length === 0) return 'Add at least one option'
if (trimmedOptions.some((o) => !o.name)) return 'Option names cannot be empty'
const names = trimmedOptions.map((o) => o.name.toLowerCase())
if (new Set(names).size !== names.length) return 'Option names must be unique'
return null
}

async function handleSave() {
if (!trimmedName) {
setShowValidation(true)
return
}

const optionsIssue = validateOptions()
if (optionsIssue) {
setOptionsError(optionsIssue)
return
}

try {
if (config.mode === 'create') {
await addColumn.mutateAsync({
name: trimmedName,
type: typeInput,
...(uniqueInput ? { unique: true } : {}),
...(wantsOptions ? { options: trimmedOptions } : {}),
})
toast.success(`Added "${trimmedName}"`)
onClose()
Expand All @@ -123,11 +156,19 @@ function ColumnConfigBody({
const renamed = trimmedName !== (existingColumn?.name ?? config.columnName)
const typeChanged = !!existingColumn && existingColumn.type !== typeInput
const uniqueChanged = !!existingColumn && !!existingColumn.unique !== uniqueInput
const optionsChanged =
wantsOptions && !optionsEqual(existingColumn?.options ?? [], trimmedOptions)

const updates: { name?: string; type?: ColumnDefinition['type']; unique?: boolean } = {
const updates: {
name?: string
type?: ColumnDefinition['type']
unique?: boolean
options?: SelectOption[]
} = {
...(renamed ? { name: trimmedName } : {}),
...(typeChanged ? { type: typeInput } : {}),
...(uniqueChanged ? { unique: uniqueInput } : {}),
...(wantsOptions && (typeChanged || optionsChanged) ? { options: trimmedOptions } : {}),
}
if (Object.keys(updates).length === 0) {
onClose()
Expand Down Expand Up @@ -207,6 +248,23 @@ function ColumnConfigBody({
</>
)}

{wantsOptions && (
<>
<FieldDivider />
<div className='flex flex-col gap-[9.5px]'>
<RequiredLabel>Options</RequiredLabel>
<SelectOptionsEditor
options={optionsInput}
onChange={(next) => {
setOptionsInput(next)
if (optionsError) setOptionsError(null)
}}
/>
{optionsError && <FieldError message={optionsError} />}
</div>
</>
)}

<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,7 +1,9 @@
import type React from 'react'
import {
Calendar as CalendarIcon,
ClipboardList,
PlayOutline,
TagIcon,
TypeBoolean,
TypeJson,
TypeNumber,
Expand All @@ -28,6 +30,8 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [
{ type: 'boolean', label: 'Boolean', icon: TypeBoolean },
{ type: 'date', label: 'Date', icon: CalendarIcon },
{ type: 'json', label: 'JSON', icon: TypeJson },
{ type: 'select', label: 'Select', icon: TagIcon },
{ type: 'multiselect', label: 'Multi-select', icon: ClipboardList },
{ type: 'workflow', label: 'Workflow', icon: PlayOutline },
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
localPartsToDateValue,
todayLocalCalendarDate,
} from '../../utils'
import { SelectValueEditor } from '../select-field'

const logger = createLogger('RowModal')

Expand Down Expand Up @@ -275,6 +276,14 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
)
}

if (column.type === 'select' || column.type === 'multiselect') {
return (
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
<SelectValueEditor column={column} value={value} onChange={onChange} fullWidth />
</ChipModalField>
)
}

return (
<ChipModalField
type='input'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { SelectOptionsEditor } from './select-options-editor'
export { resolveSelectOptions, SelectPill, toSelectedIds } from './select-pill'
export { SelectValueEditor } from './select-value-editor'
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
'use client'

import { Button, ChipInput } from '@sim/emcn'
import { Plus, X } from '@sim/emcn/icons'
import { generateShortId } from '@sim/utils/id'
import type { SelectOption } from '@/lib/table'

interface SelectOptionsEditorProps {
options: SelectOption[]
onChange: (options: SelectOption[]) => void
}

/**
* Add/remove/rename the options of a `select`/`multiselect` column. Option ids
* are stable across edits so existing cell data survives renames. Options
* default to the neutral `gray` pill — per-option colors are not yet exposed
* (the `color` field stays in the model so a picker can be re-added later).
*/
export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorProps) {
const update = (id: string, patch: Partial<SelectOption>) => {
onChange(options.map((o) => (o.id === id ? { ...o, ...patch } : o)))
}

const remove = (id: string) => {
onChange(options.filter((o) => o.id !== id))
}

const add = () => {
onChange([...options, { id: generateShortId(), name: '', color: 'gray' }])
}

return (
<div className='flex flex-col gap-1'>
{options.map((option) => (
<div key={option.id} className='flex items-center gap-1.5'>
<ChipInput
value={option.name}
onChange={(e) => update(option.id, { name: e.target.value })}
placeholder='Option name'
spellCheck={false}
autoComplete='off'
className='min-w-0 flex-1'
/>
<Button
variant='ghost'
size='sm'
onClick={() => remove(option.id)}
className='!p-1 size-7 shrink-0'
aria-label={`Remove ${option.name || 'option'}`}
>
<X className='size-[12px]' />
</Button>
</div>
))}
<Button
variant='ghost'
size='sm'
onClick={add}
className='mt-1 self-start px-2 py-1 text-[var(--text-secondary)] text-xs'
>
<Plus className='mr-1 size-[10px]' />
Add option
</Button>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use client'

import { Badge, cn } from '@sim/emcn'
import type { ColumnDefinition, SelectOption } from '@/lib/table'

/** Reads the selected option ids from a stored cell value of either select type. */
export function toSelectedIds(value: unknown): string[] {
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string')
if (typeof value === 'string' && value !== '') return [value]
return []
}

/**
* Resolves the stored ids of a `select`/`multiselect` cell to their declared
* options, preserving selection order. An id with no matching option (stale
* after an option was deleted) resolves to a neutral gray fallback so the cell
* never renders blank.
*/
export function resolveSelectOptions(column: ColumnDefinition, value: unknown): SelectOption[] {
const options = column.options ?? []
return toSelectedIds(value).map(
(id) => options.find((o) => o.id === id) ?? { id, name: id, color: 'gray' }
)
}

interface SelectPillProps {
option: SelectOption
size?: 'sm' | 'md'
className?: string
}

/** A single colored option pill, rendered through the shared `Badge` palette. */
export function SelectPill({ option, size = 'sm', className }: SelectPillProps) {
return (
<Badge variant={option.color} size={size} className={cn('max-w-full', className)}>
<span className='truncate'>{option.name}</span>
</Badge>
)
}
Loading