Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
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
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 } : {}),
}
}
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
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
41 changes: 26 additions & 15 deletions apps/sim/app/api/v1/tables/[tableId]/rows/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,23 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { Filter, RowData, TableSchema } from '@/lib/table'
import {
batchInsertRows,
buildIdByName,
buildNameById,
deleteRowsByFilter,
deleteRowsByIds,
filterNamesToIds,
insertRow,
rowDataIdToName,
rowDataNameToId,
sortNamesToIds,
updateRowsByFilter,
validateBatchRows,
validateRowData,
validateRowSize,
} from '@/lib/table'
import { namedRowMapper } from '@/lib/table/cell-format'
import {
buildIdByName,
filterNamesToIds,
rowDataNameToId,
sortNamesToIds,
} from '@/lib/table/column-keys'
import { queryRows } from '@/lib/table/rows/service'
import { resolveFilterSelectValues } from '@/lib/table/select-values'
import { TableQueryValidationError } from '@/lib/table/sql'
import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils'
import {
Expand Down Expand Up @@ -68,7 +70,7 @@ async function handleBatchInsert(

// External callers key row data by column name; storage keys by id.
const idByName = buildIdByName(table.schema as TableSchema)
const nameById = buildNameById(table.schema as TableSchema)
const toNamedRow = namedRowMapper((table.schema as TableSchema).columns)
const rows = (validated.rows as RowData[]).map((r) => rowDataNameToId(r, idByName))

const validation = await validateBatchRows({
Expand All @@ -95,7 +97,7 @@ async function handleBatchInsert(
data: {
rows: insertedRows.map((r) => ({
id: r.id,
data: rowDataIdToName(r.data, nameById),
data: toNamedRow(r.data),
position: r.position,
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt,
Expand Down Expand Up @@ -154,9 +156,12 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR

// Translate name-keyed filter/sort fields → column ids; translate rows back.
const idByName = buildIdByName(table.schema as TableSchema)
const nameById = buildNameById(table.schema as TableSchema)
const toNamedRow = namedRowMapper((table.schema as TableSchema).columns)
const filter = validated.filter
? filterNamesToIds(validated.filter as Filter, idByName)
? resolveFilterSelectValues(
filterNamesToIds(validated.filter as Filter, idByName),
(table.schema as TableSchema).columns
)
: undefined
const sort = validated.sort ? sortNamesToIds(validated.sort, idByName) : undefined

Expand All @@ -178,7 +183,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR
data: {
rows: result.rows.map((r) => ({
id: r.id,
data: rowDataIdToName(r.data, nameById),
data: toNamedRow(r.data),
position: r.position,
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
Expand Down Expand Up @@ -253,7 +258,7 @@ export const POST = withRouteHandler(
}

const idByName = buildIdByName(table.schema as TableSchema)
const nameById = buildNameById(table.schema as TableSchema)
const toNamedRow = namedRowMapper((table.schema as TableSchema).columns)
const rowData = rowDataNameToId(validated.data as RowData, idByName)

const validation = await validateRowData({
Expand All @@ -279,7 +284,7 @@ export const POST = withRouteHandler(
data: {
row: {
id: row.id,
data: rowDataIdToName(row.data, nameById),
data: toNamedRow(row.data),
position: row.position,
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt,
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt,
Expand Down Expand Up @@ -346,7 +351,10 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR
const result = await updateRowsByFilter(
table,
{
filter: filterNamesToIds(validated.filter as Filter, idByName),
filter: resolveFilterSelectValues(
filterNamesToIds(validated.filter as Filter, idByName),
(table.schema as TableSchema).columns
),
data: patchData,
limit: validated.limit,
actorUserId,
Expand Down Expand Up @@ -442,7 +450,10 @@ export const DELETE = withRouteHandler(
const result = await deleteRowsByFilter(
table,
{
filter: filterNamesToIds(validated.filter as Filter, idByName),
filter: resolveFilterSelectValues(
filterNamesToIds(validated.filter as Filter, idByName),
(table.schema as TableSchema).columns
),
limit: validated.limit,
},
requestId
Expand Down
Loading
Loading