Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
55bf638
feat(tables): add select/multiselect column types (backend)
TheodoreSpeaks Jul 22, 2026
d270a0f
feat(tables): select/multiselect column UI
TheodoreSpeaks Jul 22, 2026
1fdfa3c
fix(tables): auto-fit select columns on option labels, not ids
TheodoreSpeaks Jul 22, 2026
b5ad728
feat(tables): default select options to grey, drop color picker for now
TheodoreSpeaks Jul 23, 2026
f7c6dd8
fix(tables): guard select type conversion, escape, and required clear
TheodoreSpeaks Jul 23, 2026
fce848c
fix(tables): block emptying required multiselect, skip no-op cell writes
TheodoreSpeaks Jul 23, 2026
4db5e88
fix(tables): expanded multiselect string value + same-type options drop
TheodoreSpeaks Jul 23, 2026
a0f898a
improvement(tables): inline select edit + idiomatic options editor
TheodoreSpeaks Jul 23, 2026
0646664
improvement(tables): inline select cell uses bare DropdownMenu
TheodoreSpeaks Jul 23, 2026
2fbdc9c
improvement(tables): unify select into one type with a multiple flag
TheodoreSpeaks Jul 23, 2026
d55f94b
improvement(tables): revert select cell to chip-only view
TheodoreSpeaks Jul 23, 2026
60b15cc
fix(tables): block multiple→single select switch when cells have >1 o…
TheodoreSpeaks Jul 23, 2026
d06c399
improvement(tables): rename select "Allow multiple" toggle to "Multis…
TheodoreSpeaks Jul 23, 2026
3e0b68a
fix(tables): drop removed select options from cells instead of stale …
TheodoreSpeaks Jul 23, 2026
1138baa
improvement(tables): use dashed add-row button for select options
TheodoreSpeaks Jul 23, 2026
7cd6f77
perf(tables): don't refetch rows on metadata-only column saves; fix c…
TheodoreSpeaks Jul 23, 2026
136f38a
fix(tables): migrate legacy multiselect columns; readable contract error
TheodoreSpeaks Jul 24, 2026
b237288
revert(tables): drop legacy multiselect read-migration
TheodoreSpeaks Jul 24, 2026
0c465c8
improvement(tables): add select options by typing into a trailing row
TheodoreSpeaks Jul 24, 2026
228ba69
fix(tables): export select columns as option names, not ids
TheodoreSpeaks Jul 24, 2026
53ceaaf
fix(tables): resolve select option ids to names across all read/consu…
TheodoreSpeaks Jul 24, 2026
b9598ef
refactor(tables): remove vestigial per-option color from select columns
TheodoreSpeaks Jul 24, 2026
78fb091
feat(tables): generate select option ids in the copilot handler from …
TheodoreSpeaks Jul 24, 2026
4cb21f8
refactor(tables): collapse row read boundaries onto one outbound seam
TheodoreSpeaks Jul 24, 2026
61ccf5b
fix(tables): stream the persisted cell value, not the raw workflow ou…
TheodoreSpeaks Jul 24, 2026
31da1b6
fix(tables): keep option ids stable when the agent edits a select column
TheodoreSpeaks Jul 25, 2026
1dd7237
fix(tables): filter multi-select columns by membership, not equality
TheodoreSpeaks Jul 25, 2026
156f056
chore(tables): drop a stale doc comment and trim two restating ones
TheodoreSpeaks Jul 25, 2026
f004510
fix(tables): close review findings on select column conversion and ed…
TheodoreSpeaks Jul 25, 2026
9258f67
fix(tables): migrate select cells in both directions and refetch rows…
TheodoreSpeaks Jul 25, 2026
5af6bbf
fix(tables): align select cell migration with the compatibility check
TheodoreSpeaks Jul 25, 2026
f493565
fix(tables): treat an emptied multiselect as empty everywhere
TheodoreSpeaks Jul 25, 2026
5b4b793
fix(tables): route an unchanged type with options to the options update
TheodoreSpeaks Jul 25, 2026
e3bef6f
fix(tables): restore select options on column-delete undo; map v1 opt…
TheodoreSpeaks Jul 25, 2026
75d514b
fix(tables): clear unique in the service when converting a column to …
TheodoreSpeaks Jul 25, 2026
6ab46ed
fix(tables): clear removed select options, reject unique selects, res…
TheodoreSpeaks Jul 25, 2026
6f860fc
fix(tables): refetch rows when a select option is removed
TheodoreSpeaks Jul 25, 2026
0efab28
fix(tables): let a multiselect round-trip through text
TheodoreSpeaks Jul 25, 2026
09e2f17
fix(tables): scope the empty-selection guard to multiselect; order th…
TheodoreSpeaks Jul 25, 2026
04faaf8
fix(tables): prune stale select filters; reject unique+select before …
TheodoreSpeaks Jul 25, 2026
e09a799
fix(tables): scope bulk ops to the pruned filter; guard unique+select…
TheodoreSpeaks Jul 25, 2026
a0c9bba
fix(tables): reject the multiple flag on non-select columns
TheodoreSpeaks Jul 25, 2026
7c0c24d
fix(tables): block required blank conversions; sort multiselect by op…
TheodoreSpeaks Jul 25, 2026
7bce934
fix(tables): validate a conversion against the required flag the requ…
TheodoreSpeaks Jul 25, 2026
cc68944
fix(tables): one emptiness predicate, guard required option removal, …
TheodoreSpeaks Jul 25, 2026
33fea44
fix(tables): restore in/nin on select filters and assert the whitelis…
TheodoreSpeaks Jul 25, 2026
28d8e07
fix(tables): stop Find crashing on a null multiselect cell
TheodoreSpeaks Jul 25, 2026
46fc837
fix(tables): clear removed options before the cardinality guard and m…
TheodoreSpeaks Jul 25, 2026
4b56be6
fix(tables): validate option removal against the pending required fla…
TheodoreSpeaks Jul 25, 2026
061913c
fix(tables): gate the unique guard on the resulting type, not on a ty…
TheodoreSpeaks Jul 25, 2026
2586fe2
fix(tables): stop coercing select option ids in filter values
TheodoreSpeaks Jul 25, 2026
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
53 changes: 49 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,50 @@ 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

// Every write below is its own locked transaction, so any of them paired
// with a constraint write that is going to fail commits and then errors.
// Gate on the type the column ENDS UP with, not on whether the type is
// changing: an options-only update on an existing select column carries the
// same hazard as a conversion does.
const resultingType = updates.type ?? currentColumn?.type
if (updates.unique === true && resultingType === 'select') {
return NextResponse.json({ error: 'Cannot set a select column as unique' }, { status: 400 })
}

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 } : {}),
// Forwarded so the conversion validates against the constraint this
// same request is about to set, not the column's current one.
...(updates.required !== undefined ? { required: updates.required } : {}),
},
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 } : {}),
// Forwarded so the removal guard validates against the constraint this
// same request is about to set, not the column's current one.
...(updates.required !== undefined ? { required: updates.required } : {}),
},
Comment thread
cursor[bot] marked this conversation as resolved.
requestId
)
}
Expand Down Expand Up @@ -162,7 +206,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
msg.includes('Invalid column') ||
msg.includes('exceeds maximum') ||
msg.includes('incompatible') ||
msg.includes('duplicate')
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
msg.includes('duplicate') ||
msg.includes('option')
) {
return NextResponse.json({ error: msg }, { status: 400 })
}
Expand Down
24 changes: 6 additions & 18 deletions apps/sim/app/api/table/[tableId]/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { neutralizeCsvFormula } from '@/lib/core/utils/csv'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys'
import { namedRowMapper } from '@/lib/table/cell-format'
import { getColumnId } from '@/lib/table/column-keys'
import { formatCsvCell } from '@/lib/table/export-format'
import { queryRows } from '@/lib/table/rows/service'
import { accessError, checkAccess } from '@/app/api/table/utils'

Expand Down Expand Up @@ -52,7 +54,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
const columns = table.schema.columns
// Stored row data is id-keyed; CSV headers and JSON keys are display names, so
// translate id → name on the way out (export is a name-friendly boundary).
const nameById = buildNameById(table.schema)
const toNamedRow = namedRowMapper(columns)
const safeName = sanitizeFilename(table.name)
const filename = `${safeName}.${format}`

Expand Down Expand Up @@ -100,14 +102,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou

for (const row of result.rows) {
if (format === 'csv') {
const values = columns.map((c) => formatCsvValue(row.data[getColumnId(c)]))
const values = columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)]))
controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`))
} else {
const prefix = firstJsonRow ? '' : ','
firstJsonRow = false
controller.enqueue(
encoder.encode(prefix + JSON.stringify(rowDataIdToName(row.data, nameById)))
)
controller.enqueue(encoder.encode(prefix + JSON.stringify(toNamedRow(row.data))))
}
}

Expand Down Expand Up @@ -144,18 +144,6 @@ function sanitizeFilename(name: string): string {
return cleaned || 'table'
}

/**
* Serializes a cell for CSV. Only string cells are formula-neutralized; numbers,
* booleans, dates, and JSON objects can never form a trigger and pass through verbatim.
*/
function formatCsvValue(value: unknown): string {
if (value === null || value === undefined) return ''
if (value instanceof Date) return value.toISOString()
if (typeof value === 'object') return JSON.stringify(value)
if (typeof value === 'string') return neutralizeCsvFormula(value)
return String(value)
}

function toCsvRow(values: string[]): string {
return values.map(escapeCsvField).join(',')
}
Expand Down
13 changes: 7 additions & 6 deletions apps/sim/app/api/table/row-wire.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { AuthType, type AuthTypeValue } from '@/lib/auth/hybrid'
import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
import { namedRowMapper } from '@/lib/table/cell-format'
import {
buildIdByName,
buildNameById,
filterNamesToIds,
rowDataIdToName,
rowDataNameToId,
sortNamesToIds,
} from '@/lib/table'
} from '@/lib/table/column-keys'
import { resolveFilterSelectValues } from '@/lib/table/select-values'

export interface RowWireTranslators {
/** Inbound row data: wire keys → storage column ids. */
Expand Down Expand Up @@ -36,11 +36,12 @@ export function rowWireTranslators(
return { dataIn: identity, dataOut: identity, filterIn: identity, sortIn: identity }
}
const idByName = buildIdByName(schema)
const nameById = buildNameById(schema)
return {
dataOut: namedRowMapper(schema.columns),
dataIn: (data) => rowDataNameToId(data, idByName),
dataOut: (data) => rowDataIdToName(data, nameById),
filterIn: (filter) => filterNamesToIds(filter, idByName),
// Rekey field refs name → id, then resolve select operand names → ids.
filterIn: (filter) =>
resolveFilterSelectValues(filterNamesToIds(filter, idByName), schema.columns),
sortIn: (sort) => sortNamesToIds(sort, idByName),
}
}
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 } : {}),
}
}
60 changes: 56 additions & 4 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 @@ -86,7 +88,15 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
if (validationResponse) return validationResponse

if (error instanceof Error) {
if (error.message.includes('already exists') || error.message.includes('maximum column')) {
// Same caller-error set the internal columns route maps — an invalid
// select option set is a bad request, not a server fault.
if (
error.message.includes('already exists') ||
error.message.includes('maximum column') ||
error.message.includes('Invalid column') ||
error.message.includes('exceeds maximum') ||
error.message.includes('option')
) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
if (error.message === 'Table not found') {
Expand Down Expand Up @@ -138,9 +148,50 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
)
}

if (updates.type) {
Comment thread
cursor[bot] marked this conversation as resolved.
// 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

// Every write below is its own locked transaction, so any of them paired
// with a constraint write that is going to fail commits and then errors.
// Gate on the type the column ENDS UP with, not on whether the type is
// changing: an options-only update on an existing select column carries the
// same hazard as a conversion does.
const resultingType = updates.type ?? currentColumn?.type
if (updates.unique === true && resultingType === 'select') {
return NextResponse.json({ error: 'Cannot set a select column as unique' }, { status: 400 })
}

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 } : {}),
// Forwarded so the conversion validates against the constraint this
// same request is about to set, not the column's current one.
...(updates.required !== undefined ? { required: updates.required } : {}),
},
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 } : {}),
// Forwarded so the removal guard validates against the constraint this
// same request is about to set, not the column's current one.
...(updates.required !== undefined ? { required: updates.required } : {}),
},
requestId
)
}
Expand Down Expand Up @@ -195,7 +246,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
18 changes: 7 additions & 11 deletions apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,9 @@ import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { RowData, TableSchema } from '@/lib/table'
import {
buildIdByName,
buildNameById,
rowDataIdToName,
rowDataNameToId,
updateRow,
} from '@/lib/table'
import { updateRow } from '@/lib/table'
import { namedRowMapper } from '@/lib/table/cell-format'
import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys'
import { accessError, checkAccess } from '@/app/api/table/utils'
import {
checkRateLimit,
Expand Down Expand Up @@ -88,13 +84,13 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}

const nameById = buildNameById(result.table.schema as TableSchema)
const toNamedRow = namedRowMapper((result.table.schema as TableSchema).columns)
return NextResponse.json({
success: true,
data: {
row: {
id: row.id,
data: rowDataIdToName(row.data as RowData, nameById),
data: toNamedRow(row.data as RowData),
position: row.position,
createdAt:
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
Expand Down Expand Up @@ -142,7 +138,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
}

const idByName = buildIdByName(table.schema as TableSchema)
const nameById = buildNameById(table.schema as TableSchema)
const toNamedRow = namedRowMapper((table.schema as TableSchema).columns)
const updatedRow = await updateRow(
{
tableId,
Expand All @@ -168,7 +164,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
data: {
row: {
id: updatedRow.id,
data: rowDataIdToName(updatedRow.data, nameById),
data: toNamedRow(updatedRow.data),
position: updatedRow.position,
createdAt:
updatedRow.createdAt instanceof Date
Expand Down
Loading
Loading