diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index 7eecb5ee466..c749ddbbff3 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -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') @@ -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 }) } @@ -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, + ...(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 ) } @@ -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') + msg.includes('duplicate') || + msg.includes('option') ) { return NextResponse.json({ error: msg }, { status: 400 }) } diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index df217ec7d31..6d717bdcdc3 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -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' @@ -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}` @@ -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)))) } } @@ -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(',') } diff --git a/apps/sim/app/api/table/row-wire.ts b/apps/sim/app/api/table/row-wire.ts index 89c4cb93af7..d8458e56ec5 100644 --- a/apps/sim/app/api/table/row-wire.ts +++ b/apps/sim/app/api/table/row-wire.ts @@ -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. */ @@ -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), } } diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 7424258ad0e..0b2c07ba1f5 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -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 } : {}), } } diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index 0eeebfb99ce..55b4e0a81d8 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -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, @@ -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') { @@ -138,9 +148,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, + ...(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 ) } @@ -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 }) } diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 419da80ad35..c7040a0239f 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -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, @@ -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), @@ -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, @@ -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 diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index c1ffacf2218..55bfeec39b3 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -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 { @@ -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({ @@ -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, @@ -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 @@ -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), @@ -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({ @@ -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, @@ -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, @@ -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 diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index caf0e0a6df2..d35e34646aa 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -6,13 +6,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, - upsertRow, -} from '@/lib/table' +import { upsertRow } 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, @@ -63,7 +59,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser } const idByName = buildIdByName(table.schema as TableSchema) - const nameById = buildNameById(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) const upsertResult = await upsertRow( { tableId, @@ -81,7 +77,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser data: { row: { id: upsertResult.row.id, - data: rowDataIdToName(upsertResult.row.data, nameById), + data: toNamedRow(upsertResult.row.data), createdAt: upsertResult.row.createdAt instanceof Date ? upsertResult.row.createdAt.toISOString() diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx index 0f21afa12cc..2c0a9cffda4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx @@ -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 (``) so this surface @@ -94,11 +104,30 @@ function ColumnConfigBody({ const [uniqueInput, setUniqueInput] = useState(() => config.mode === 'edit' ? !!existingColumn?.unique : false ) + const [optionsInput, setOptionsInput] = useState(() => + config.mode === 'edit' ? (existingColumn?.options ?? []) : [] + ) + const [multipleInput, setMultipleInput] = useState(() => + config.mode === 'edit' ? !!existingColumn?.multiple : false + ) const [showValidation, setShowValidation] = useState(false) const [nameError, setNameError] = useState(null) + const [optionsError, setOptionsError] = useState(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) { @@ -106,12 +135,21 @@ function ColumnConfigBody({ 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() @@ -122,12 +160,28 @@ 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 + // Select columns don't offer a Unique control, so converting a unique + // column to select would strand the constraint with no way to clear it. + const uniqueCleared = wantsOptions && !!existingColumn?.unique + 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 } : {}), + ...(uniqueCleared ? { unique: false } : {}), + ...(wantsOptions && (typeChanged || optionsChanged) ? { options: trimmedOptions } : {}), + ...(wantsOptions && (typeChanged || multipleChanged) ? { multiple: multipleInput } : {}), } if (Object.keys(updates).length === 0) { onClose() @@ -207,17 +261,48 @@ function ColumnConfigBody({ )} - -
-
- - setUniqueInput(!!v)} - /> -
-
+ {wantsOptions && ( + <> + +
+ Options + { + setOptionsInput(next) + if (optionsError) setOptionsError(null) + }} + /> + {optionsError && } +
+ +
+ + setMultipleInput(!!v)} + /> +
+ + )} + + {/* Select columns don't expose a unique constraint. */} + {!wantsOptions && ( + <> + +
+
+ + setUniqueInput(!!v)} + /> +
+
+ + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts index 9b4a7af4790..6ca8023e80b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts @@ -2,6 +2,7 @@ import type React from 'react' import { Calendar as CalendarIcon, PlayOutline, + TagIcon, TypeBoolean, TypeJson, TypeNumber, @@ -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 }, ] diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx index 28c971bda9f..efee32d04e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx @@ -27,6 +27,7 @@ import { localPartsToDateValue, todayLocalCalendarDate, } from '../../utils' +import { SelectValueEditor } from '../select-field' const logger = createLogger('RowModal') @@ -275,6 +276,14 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) { ) } + if (column.type === 'select') { + return ( + + + + ) + } + return ( void +} + +/** + * Add/remove/rename the options of a `select` column. Option ids are stable + * across edits so existing cell data survives renames. New options are added by + * typing into the trailing empty row — the first keystroke materializes the + * option and focus jumps into it so typing flows straight through. + */ +export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorProps) { + const inputRefs = useRef>(new Map()) + const trailingRef = useRef(null) + const [pendingFocusId, setPendingFocusId] = useState(null) + + // The new row and `pendingFocusId` land in the same commit, so the ref is + // registered by the time this effect runs. + useEffect(() => { + if (!pendingFocusId) return + const el = inputRefs.current.get(pendingFocusId) + if (el) { + el.focus() + const end = el.value.length + el.setSelectionRange(end, end) + } + setPendingFocusId(null) + }, [pendingFocusId]) + + const update = (id: string, patch: Partial) => { + onChange(options.map((o) => (o.id === id ? { ...o, ...patch } : o))) + } + + const remove = (id: string) => { + inputRefs.current.delete(id) + onChange(options.filter((o) => o.id !== id)) + } + + /** Typing into the trailing row promotes it to a real option and keeps focus. */ + const materialize = (name: string) => { + const id = generateShortId() + onChange([...options, { id, name }]) + setPendingFocusId(id) + } + + return ( +
+ {options.map((option) => ( +
+ { + if (el) inputRefs.current.set(option.id, el) + else inputRefs.current.delete(option.id) + }} + value={option.name} + onChange={(e) => update(option.id, { name: e.target.value })} + onKeyDown={(e) => { + // Enter jumps to the trailing row so options can be added in a row. + if (e.key === 'Enter') { + e.preventDefault() + trailingRef.current?.focus() + } + }} + placeholder='Option name' + spellCheck={false} + autoComplete='off' + className='min-w-0 flex-1' + /> + +
+ ))} +
+ { + if (e.target.value) materialize(e.target.value) + }} + placeholder='Add option' + spellCheck={false} + autoComplete='off' + className='min-w-0 flex-1' + /> + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-pill.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-pill.tsx new file mode 100644 index 00000000000..699d6d2b5f4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-pill.tsx @@ -0,0 +1,44 @@ +'use client' + +import { Badge, cn } from '@sim/emcn' +import type { ColumnDefinition, SelectOption } from '@/lib/table' + +/** Reads the raw stored option ids from a cell value (single string or array). */ +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 a `select` cell's stored ids to their declared options, preserving + * selection order. An id with no matching option — stale after that option was + * deleted — is dropped, so the cell falls back to empty ("None") rather than + * showing an orphaned reference. + */ +export function resolveSelectOptions(column: ColumnDefinition, value: unknown): SelectOption[] { + const byId = new Map((column.options ?? []).map((o) => [o.id, o])) + return toSelectedIds(value) + .map((id) => byId.get(id)) + .filter((o): o is SelectOption => o != null) +} + +/** The still-valid option ids of a cell (orphaned/removed ids dropped). */ +export function selectedOptionIds(column: ColumnDefinition, value: unknown): string[] { + return resolveSelectOptions(column, value).map((o) => o.id) +} + +interface SelectPillProps { + option: SelectOption + size?: 'sm' | 'md' + className?: string +} + +/** A single option pill, rendered through the shared neutral `Badge`. */ +export function SelectPill({ option, size = 'sm', className }: SelectPillProps) { + return ( + + {option.name} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-value-editor.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-value-editor.tsx new file mode 100644 index 00000000000..cdd89e8e821 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-value-editor.tsx @@ -0,0 +1,87 @@ +'use client' + +import { useMemo } from 'react' +import { ChipDropdown } from '@sim/emcn' +import type { ColumnDefinition } from '@/lib/table' +import { SelectPill, selectedOptionIds } from './select-pill' + +interface SelectValueEditorProps { + column: ColumnDefinition + value: unknown + /** Single columns emit a string id or null; multiselect emits a string[]. */ + onChange: (next: string | string[] | null) => void + fullWidth?: boolean + align?: 'start' | 'center' | 'end' +} + +const CLEAR_VALUE = '' + +/** + * Option picker for `select`/`multiselect` cells in a form context (the row + * modal) — a `ChipDropdown` pill that lists each option as its colored pill and + * writes option ids back through `onChange`. Inline grid editing uses a bare + * `DropdownMenu` instead (see `InlineSelectEditor`). + */ +export function SelectValueEditor({ + column, + value, + onChange, + fullWidth, + align = 'start', +}: SelectValueEditorProps) { + const isMulti = !!column.multiple + const options = useMemo( + () => + (column.options ?? []).map((option) => ({ + value: option.id, + label: , + })), + [column.options] + ) + + if (isMulti) { + return ( + { + if (column.required && ids.length === 0) return + onChange(ids) + }} + options={options} + showAllOption={false} + // In multiple mode ChipDropdown ignores `placeholder` and renders + // `allLabel` when nothing is selected — which would read as if every + // option were chosen. There is no "All" entry here, so this is the + // empty label. + allLabel='Select options' + align={align} + fullWidth={fullWidth} + matchTriggerWidth={false} + /> + ) + } + + // Offer a "None" entry to clear the cell — except on a required column, where + // clearing to null can never be committed (required validation rejects it). + const singleOptions = column.required + ? options + : [ + { value: CLEAR_VALUE, label: None }, + ...options, + ] + + return ( + onChange(id === CLEAR_VALUE ? null : id)} + options={singleOptions} + placeholder='Select an option' + align={align} + fullWidth={fullWidth} + matchTriggerWidth={false} + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx index 4073f24e098..a0af627d296 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx @@ -6,9 +6,25 @@ import { Plus, X } from '@sim/emcn/icons' import { generateShortId } from '@sim/utils/id' import type { ColumnDefinition, Filter, FilterRule } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' -import { COMPARISON_OPERATORS, VALUELESS_OPERATORS } from '@/lib/table/query-builder/constants' +import { + COMPARISON_OPERATORS, + MULTI_SELECT_FILTER_OPERATORS, + SINGLE_SELECT_FILTER_OPERATORS, + VALUELESS_OPERATORS, +} from '@/lib/table/query-builder/constants' import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters' +const SINGLE_SELECT_COMPARISON_OPERATORS = COMPARISON_OPERATORS.filter((o) => + SINGLE_SELECT_FILTER_OPERATORS.has(o.value) +) +const MULTI_SELECT_COMPARISON_OPERATORS = COMPARISON_OPERATORS.filter((o) => + MULTI_SELECT_FILTER_OPERATORS.has(o.value) +) + +function selectFilterOperators(column: ColumnDefinition | undefined): Set { + return column?.multiple ? MULTI_SELECT_FILTER_OPERATORS : SINGLE_SELECT_FILTER_OPERATORS +} + interface TableFilterProps { columns: ColumnDefinition[] filter: Filter | null @@ -31,6 +47,11 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr [columns] ) + const columnById = useMemo( + () => new Map(columns.map((col) => [getColumnId(col), col])), + [columns] + ) + const handleAdd = useCallback(() => { setRules((prev) => [...prev, createRule(columns)]) }, [columns]) @@ -53,6 +74,32 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr setRules((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: value } : r))) }, []) + // Switching a rule's column across the select boundary changes what values and + // operators are valid, so clear the value and coerce an unsupported operator + // back to `eq` — otherwise a stale free-text value or a range operator would + // apply against a select column and be rejected server-side. + const handleColumnChange = useCallback( + (id: string, columnId: string) => { + setRules((prev) => + prev.map((r) => { + if (r.id !== id) return r + const previous = columnById.get(r.column) + const next = columnById.get(columnId) + const wasSelect = previous?.type === 'select' + const isSelect = next?.type === 'select' + if (!wasSelect && !isSelect) return { ...r, column: columnId } + // Single- and multi-select take different operators, so a switch + // between them has to fall back too, not just select ↔ non-select. + const allowed = selectFilterOperators(next) + const fallback = next?.multiple ? 'contains' : 'eq' + const operator = isSelect && !allowed.has(r.operator) ? fallback : r.operator + return { ...r, column: columnId, operator, value: '' } + }) + ) + }, + [columnById] + ) + const handleToggleLogical = useCallback((id: string) => { setRules((prev) => prev.map((r) => @@ -65,8 +112,8 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr const validRules = rulesRef.current.filter( (r) => r.column && (r.value || VALUELESS_OPERATORS.has(r.operator)) ) - onApply(filterRulesToFilter(validRules)) - }, [onApply]) + onApply(filterRulesToFilter(validRules, columns)) + }, [columns, onApply]) const handleClear = useCallback(() => { setRules([createRule(columns)]) @@ -82,7 +129,9 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr rule={rule} isFirst={index === 0} columns={columnOptions} + columnById={columnById} onUpdate={handleUpdate} + onColumnChange={handleColumnChange} onRemove={handleRemove} onApply={handleApply} onToggleLogical={handleToggleLogical} @@ -124,7 +173,9 @@ interface FilterRuleRowProps { rule: FilterRule isFirst: boolean columns: Array<{ value: string; label: string }> + columnById: ReadonlyMap onUpdate: (id: string, field: keyof FilterRule, value: string) => void + onColumnChange: (id: string, columnId: string) => void onRemove: (id: string) => void onApply: () => void onToggleLogical: (id: string) => void @@ -134,7 +185,9 @@ const FilterRuleRow = memo(function FilterRuleRow({ rule, isFirst, columns, + columnById, onUpdate, + onColumnChange, onRemove, onApply, onToggleLogical, @@ -147,6 +200,24 @@ const FilterRuleRow = memo(function FilterRuleRow({ ? [...columns, { value: rule.column, label: rule.column }] : columns + const selectedColumn = columnById.get(rule.column) + const isSelect = selectedColumn?.type === 'select' + const operatorOptions = !isSelect + ? COMPARISON_OPERATORS + : selectedColumn?.multiple + ? MULTI_SELECT_COMPARISON_OPERATORS + : SINGLE_SELECT_COMPARISON_OPERATORS + + // A stale id (option since deleted) stays selectable so the rule still shows. + const selectValueOptions = isSelect + ? (() => { + const opts = (selectedColumn.options ?? []).map((o) => ({ value: o.id, label: o.name })) + return rule.value && !opts.some((o) => o.value === rule.value) + ? [...opts, { value: rule.value, label: rule.value }] + : opts + })() + : [] + return (
{isFirst ? ( @@ -163,7 +234,7 @@ const FilterRuleRow = memo(function FilterRuleRow({ onUpdate(rule.id, 'column', value)} + onChange={(value) => onColumnChange(rule.id, value)} placeholder='Column' align='start' matchTriggerWidth={false} @@ -171,7 +242,7 @@ const FilterRuleRow = memo(function FilterRuleRow({ /> onUpdate(rule.id, 'operator', value)} placeholder='Operator' @@ -182,6 +253,16 @@ const FilterRuleRow = memo(function FilterRuleRow({ {VALUELESS_OPERATORS.has(rule.operator) ? (
+ ) : isSelect ? ( + onUpdate(rule.id, 'value', value)} + placeholder='Select a value' + align='start' + matchTriggerWidth={false} + className='min-w-[100px] flex-1' + /> ) : ( ) + case 'select': + // Chip-only view: just the option pills. Pills stay visible while editing — + // the inline editor overlays an invisible trigger and portals its menu + // below, so the cell keeps showing the current selection. + return ( + + {kind.options.length > 0 ? ( + kind.options.map((option) => ) + ) : ( + None + )} + + ) + case 'json': return ( (() => selectedOptionIds(column, value)) + const [open, setOpen] = useState(true) + const latestRef = useRef(draft) + const doneRef = useRef(false) + const cancelledRef = useRef(false) + + const setDraftAnd = (next: string[]) => { + latestRef.current = next + setDraft(next) + } + + const commit = useCallback(() => { + if (doneRef.current) return + doneRef.current = true + if (cancelledRef.current) { + onCancel() + return + } + const ids = latestRef.current + onSave(isMulti ? ids : (ids[0] ?? null), 'enter') + }, [isMulti, onSave, onCancel]) + + // Escape closes the Radix menu (firing `onOpenChange(false)`); capture it + // first so the close handler discards instead of committing. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') cancelledRef.current = true + } + document.addEventListener('keydown', onKeyDown, true) + return () => document.removeEventListener('keydown', onKeyDown, true) + }, []) + + const handleOpenChange = (next: boolean) => { + setOpen(next) + if (!next) commit() + } + + const handleSelectOption = (event: Event, id: string) => { + if (!isMulti) { + // Picking closes the menu → `handleOpenChange` commits the new value. + setDraftAnd([id]) + return + } + // Keep the menu open across toggles; commit the set on close. + event.preventDefault() + const has = latestRef.current.includes(id) + const next = has ? latestRef.current.filter((v) => v !== id) : [...latestRef.current, id] + // A required multiselect can't be emptied — ignore removing the last option. + if (column.required && next.length === 0) return + setDraftAnd(next) + } + + return ( + + +