Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 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,34 @@ 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 } : {}),
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
},
requestId
)
} else if (updates.options !== undefined || updates.multiple !== undefined) {
updatedTable = await updateColumnOptions(
{
tableId,
columnName: updates.name ?? validated.columnName,
options: updates.options ?? currentColumn?.options ?? [],
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
},
requestId
)
}
Expand Down Expand Up @@ -162,7 +190,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
2 changes: 2 additions & 0 deletions apps/sim/app/api/table/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,5 +279,7 @@ export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
required: col.required ?? false,
unique: col.unique ?? false,
...(col.workflowGroupId ? { workflowGroupId: col.workflowGroupId } : {}),
...(col.options ? { options: col.options } : {}),
...(col.multiple ? { multiple: true } : {}),
}
}
34 changes: 31 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,34 @@ 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 } : {}),
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
},
requestId
)
} else if (updates.options !== undefined || updates.multiple !== undefined) {
updatedTable = await updateColumnOptions(
{
tableId,
columnName: updates.name ?? validated.columnName,
options: updates.options ?? currentColumn?.options ?? [],
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
},
requestId
)
}
Expand Down Expand Up @@ -195,7 +222,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'
}

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,52 @@ function ColumnConfigBody({
const [uniqueInput, setUniqueInput] = useState<boolean>(() =>
config.mode === 'edit' ? !!existingColumn?.unique : false
)
const [optionsInput, setOptionsInput] = useState<SelectOption[]>(() =>
config.mode === 'edit' ? (existingColumn?.options ?? []) : []
)
const [multipleInput, setMultipleInput] = useState<boolean>(() =>
config.mode === 'edit' ? !!existingColumn?.multiple : false
)
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 } : {}),
// Select columns don't expose a unique constraint.
...(!wantsOptions && uniqueInput ? { unique: true } : {}),
...(wantsOptions ? { options: trimmedOptions } : {}),
...(wantsOptions && multipleInput ? { multiple: true } : {}),
})
toast.success(`Added "${trimmedName}"`)
onClose()
Expand All @@ -122,12 +160,24 @@ function ColumnConfigBody({
// name to detect an actual rename.
const renamed = trimmedName !== (existingColumn?.name ?? config.columnName)
const typeChanged = !!existingColumn && existingColumn.type !== typeInput
const uniqueChanged = !!existingColumn && !!existingColumn.unique !== uniqueInput
const uniqueChanged =
!wantsOptions && !!existingColumn && !!existingColumn.unique !== uniqueInput
const optionsChanged =
wantsOptions && !optionsEqual(existingColumn?.options ?? [], trimmedOptions)
const multipleChanged = wantsOptions && !!existingColumn?.multiple !== multipleInput

const updates: { name?: string; type?: ColumnDefinition['type']; unique?: boolean } = {
const updates: {
name?: string
type?: ColumnDefinition['type']
unique?: boolean
options?: SelectOption[]
multiple?: boolean
} = {
...(renamed ? { name: trimmedName } : {}),
...(typeChanged ? { type: typeInput } : {}),
...(uniqueChanged ? { unique: uniqueInput } : {}),
...(wantsOptions && (typeChanged || optionsChanged) ? { options: trimmedOptions } : {}),
...(wantsOptions && (typeChanged || multipleChanged) ? { multiple: multipleInput } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unique stuck on select convert

Low Severity

Converting a unique column to select hides the Unique control and suppresses uniqueChanged, and the save payload never clears unique. A previously unique column therefore keeps its unique constraint after becoming select, with no way to turn it off while the type remains select.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d06c399. Configure here.

}
if (Object.keys(updates).length === 0) {
onClose()
Expand Down Expand Up @@ -207,17 +257,48 @@ function ColumnConfigBody({
</>
)}

<FieldDivider />
<div className='flex flex-col gap-[9.5px]'>
<div className='flex items-center justify-between pl-0.5'>
<Label htmlFor='column-sidebar-unique'>Unique</Label>
<Switch
id='column-sidebar-unique'
checked={uniqueInput}
onCheckedChange={(v) => setUniqueInput(!!v)}
/>
</div>
</div>
{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 items-center justify-between pl-0.5'>
<Label htmlFor='column-sidebar-multiple'>Multiselect</Label>
<Switch
id='column-sidebar-multiple'
checked={multipleInput}
onCheckedChange={(v) => setMultipleInput(!!v)}
/>
</div>
</>
)}

{/* Select columns don't expose a unique constraint. */}
{!wantsOptions && (
<>
<FieldDivider />
<div className='flex flex-col gap-[9.5px]'>
<div className='flex items-center justify-between pl-0.5'>
<Label htmlFor='column-sidebar-unique'>Unique</Label>
<Switch
id='column-sidebar-unique'
checked={uniqueInput}
onCheckedChange={(v) => setUniqueInput(!!v)}
/>
</div>
</div>
</>
)}
</div>

<div className='flex items-center justify-end gap-2 border-[var(--border)] border-t px-2 py-3'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type React from 'react'
import {
Calendar as CalendarIcon,
PlayOutline,
TagIcon,
TypeBoolean,
TypeJson,
TypeNumber,
Expand All @@ -28,6 +29,7 @@ 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: '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') {
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'
Loading
Loading