From 55bf63867cbe17132977b63f1e13cbadfa43b87b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 22 Jul 2026 14:31:42 -0700 Subject: [PATCH 01/20] feat(tables): add select/multiselect column types (backend) Adds two enum-style column types where the column declares a fixed set of options (stable id + name + palette color) and every cell is constrained to them. - COLUMN_TYPES gains `select` / `multiselect`; SELECT_COLORS is a fixed, theme-aware palette mapping 1:1 to Badge color variants (no raw hex) - ColumnDefinition.options carries the option set. Cells store option *ids* (a string for select, string[] for multiselect) so renaming or recoloring an option never rewrites row data - Row validation enforces membership; coercion tolerantly maps an option *name* to its id for tool/import writes and drops unmatched entries - validateColumnDefinition enforces non-empty, unique-id, unique-name and valid-color option sets, and rejects options on non-select columns - Contract gains selectOptionSchema plus a cross-field refine requiring options exactly on select types; routes thread options through type changes and a new options-only updateColumnOptions path No migration: column config already lives in the user_table_definitions schema JSONB blob. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014e4MvFV1szLhNLap1CCNUd --- .../app/api/table/[tableId]/columns/route.ts | 19 +- apps/sim/app/api/table/utils.ts | 1 + .../api/v1/tables/[tableId]/columns/route.ts | 16 +- apps/sim/lib/api/contracts/tables.ts | 114 +++++++++--- apps/sim/lib/table/columns/service.ts | 78 +++++++- apps/sim/lib/table/constants.ts | 33 +++- apps/sim/lib/table/types.ts | 25 ++- apps/sim/lib/table/validation.test.ts | 172 ++++++++++++++++++ apps/sim/lib/table/validation.ts | 113 +++++++++++- 9 files changed, 530 insertions(+), 41 deletions(-) create mode 100644 apps/sim/lib/table/validation.test.ts diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index 7eecb5ee466..12f8f266c37 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -15,6 +15,7 @@ import { deleteColumn, renameColumn, updateColumnConstraints, + updateColumnOptions, updateColumnType, } from '@/lib/table' import { accessError, checkAccess, normalizeColumn, rootErrorMessage } from '@/app/api/table/utils' @@ -68,7 +69,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 }) } @@ -118,7 +120,17 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu if (updates.type) { updatedTable = await updateColumnType( - { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, + { + tableId, + columnName: updates.name ?? validated.columnName, + newType: updates.type, + ...(updates.options !== undefined ? { options: updates.options } : {}), + }, + requestId + ) + } else if (updates.options !== undefined) { + updatedTable = await updateColumnOptions( + { tableId, columnName: updates.name ?? validated.columnName, options: updates.options }, requestId ) } @@ -162,7 +174,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/utils.ts b/apps/sim/app/api/table/utils.ts index 7424258ad0e..3ba93f99fb7 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -279,5 +279,6 @@ export function normalizeColumn(col: ColumnDefinition): ColumnDefinition { required: col.required ?? false, unique: col.unique ?? false, ...(col.workflowGroupId ? { workflowGroupId: col.workflowGroupId } : {}), + ...(col.options ? { options: col.options } : {}), } } 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..9d2a710559b 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -14,6 +14,7 @@ import { deleteColumn, renameColumn, updateColumnConstraints, + updateColumnOptions, updateColumnType, } from '@/lib/table' import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' @@ -140,7 +141,17 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu if (updates.type) { updatedTable = await updateColumnType( - { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, + { + tableId, + columnName: updates.name ?? validated.columnName, + newType: updates.type, + ...(updates.options !== undefined ? { options: updates.options } : {}), + }, + requestId + ) + } else if (updates.options !== undefined) { + updatedTable = await updateColumnOptions( + { tableId, columnName: updates.name ?? validated.columnName, options: updates.options }, requestId ) } @@ -195,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/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index c357cc585e8..59dd48385d6 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -13,7 +13,13 @@ import type { TableRow, TableRowsCursor, } from '@/lib/table' -import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' +import { + COLUMN_TYPES, + MAX_SELECT_OPTIONS, + NAME_PATTERN, + SELECT_COLORS, + TABLE_LIMITS, +} from '@/lib/table/constants' import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' export const domainObjectSchema = () => z.custom(isRecordLike) @@ -24,6 +30,50 @@ export const domainObjectSchema = () => z.custom(isRecordLike) */ export const columnTypeSchema = z.enum(COLUMN_TYPES) +/** Fixed palette token for a `select`/`multiselect` option. */ +export const selectColorSchema = z.enum(SELECT_COLORS) + +/** One choice in a `select`/`multiselect` column. `id` is the stable cell key. */ +export const selectOptionSchema = z.object({ + id: z.string().min(1, 'Option id is required'), + name: z + .string() + .min(1, 'Option name is required') + .max(100, 'Option name must be 100 characters or less'), + color: selectColorSchema, +}) + +export const selectOptionsSchema = z + .array(selectOptionSchema) + .max(MAX_SELECT_OPTIONS, `A select column cannot have more than ${MAX_SELECT_OPTIONS} options`) + +/** + * Cross-field rule: `select`/`multiselect` columns must declare a non-empty + * option set; other types must not carry options. Skipped when `type` is absent + * (an options-only update on an existing select column). + */ +function refineColumnOptions( + data: { type?: (typeof COLUMN_TYPES)[number]; options?: z.infer }, + ctx: z.RefinementCtx +): void { + const isSelect = data.type === 'select' || data.type === 'multiselect' + if (isSelect) { + if (!data.options || data.options.length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['options'], + message: 'A select column must define at least one option', + }) + } + } else if (data.type !== undefined && data.options && data.options.length > 0) { + ctx.addIssue({ + code: 'custom', + path: ['options'], + message: 'options are only allowed on select or multiselect columns', + }) + } +} + /** * Identifier for tables/columns: starts with letter or underscore, contains * only alphanumerics + underscores, capped at `MAX_TABLE_NAME_LENGTH`. @@ -78,16 +128,20 @@ export const getTableQuerySchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), }) -export const tableColumnSchema = z.object({ - /** Stable column id (server-assigned). Absent on legacy/ pre-backfill columns. */ - id: z.string().optional(), - name: columnNameSchema, - type: columnTypeSchema, - required: z.boolean().optional().default(false), - unique: z.boolean().optional().default(false), - /** Set when the column is a workflow group's output. */ - workflowGroupId: z.string().optional(), -}) +export const tableColumnSchema = z + .object({ + /** Stable column id (server-assigned). Absent on legacy/ pre-backfill columns. */ + id: z.string().optional(), + name: columnNameSchema, + type: columnTypeSchema, + required: z.boolean().optional().default(false), + unique: z.boolean().optional().default(false), + /** Set when the column is a workflow group's output. */ + workflowGroupId: z.string().optional(), + /** Declared options for a `select`/`multiselect` column. */ + options: selectOptionsSchema.optional(), + }) + .superRefine(refineColumnOptions) export const createTableBodySchema = z.object({ name: tableNameSchema, @@ -112,27 +166,33 @@ export const renameTableBodySchema = z.object({ export const createTableColumnBodySchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), - column: z.object({ - // Optional stable id — first-party undo of a delete re-creates the column - // with its original id so saved (id-keyed) cell data restores correctly. - id: z.string().optional(), - name: columnNameSchema, - type: columnTypeSchema, - required: z.boolean().optional(), - unique: z.boolean().optional(), - position: z.number().int().min(0).optional(), - }), + column: z + .object({ + // Optional stable id — first-party undo of a delete re-creates the column + // with its original id so saved (id-keyed) cell data restores correctly. + id: z.string().optional(), + name: columnNameSchema, + type: columnTypeSchema, + required: z.boolean().optional(), + unique: z.boolean().optional(), + position: z.number().int().min(0).optional(), + options: selectOptionsSchema.optional(), + }) + .superRefine(refineColumnOptions), }) export const updateTableColumnBodySchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), columnName: columnNameSchema, - updates: z.object({ - name: columnNameSchema.optional(), - type: columnTypeSchema.optional(), - required: z.boolean().optional(), - unique: z.boolean().optional(), - }), + updates: z + .object({ + name: columnNameSchema.optional(), + type: columnTypeSchema.optional(), + required: z.boolean().optional(), + unique: z.boolean().optional(), + options: selectOptionsSchema.optional(), + }) + .superRefine(refineColumnOptions), }) export const deleteTableColumnBodySchema = z.object({ diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 4eafabd456d..cfd9ed50d9c 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -22,12 +22,15 @@ import type { DeleteColumnData, RenameColumnData, RowData, + SelectOption, TableDefinition, TableMetadata, TableSchema, UpdateColumnConstraintsData, + UpdateColumnOptionsData, UpdateColumnTypeData, } from '@/lib/table/types' +import { validateColumnDefinition } from '@/lib/table/validation' import { assertValidSchema, stripGroupDeps } from '@/lib/table/workflow-columns' const logger = createLogger('TableColumnService') @@ -50,6 +53,7 @@ export async function addTableColumn( required?: boolean unique?: boolean position?: number + options?: SelectOption[] }, requestId: string ): Promise { @@ -91,7 +95,14 @@ export async function addTableColumn( type: column.type as TableSchema['columns'][number]['type'], required: column.required ?? false, unique: column.unique ?? false, + ...(column.options ? { options: column.options } : {}), } + + const columnValidation = validateColumnDefinition(newColumn) + if (!columnValidation.valid) { + throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) + } + const newColumnId = getColumnId(newColumn) const columns = [...schema.columns] @@ -519,9 +530,21 @@ export async function updateColumnType( ) } - const updatedColumns = schema.columns.map((c, i) => - i === columnIndex ? { ...c, type: data.newType } : c - ) + const isSelectType = data.newType === 'select' || data.newType === 'multiselect' + const updatedColumns = schema.columns.map((c, i) => { + if (i !== columnIndex) return c + // Drop any prior options, then re-add when the target type uses them. + const { options: _prevOptions, ...rest } = c + return isSelectType + ? { ...rest, type: data.newType, options: data.options ?? c.options } + : { ...rest, type: data.newType } + }) + + const columnValidation = validateColumnDefinition(updatedColumns[columnIndex]) + if (!columnValidation.valid) { + throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) + } + const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } const now = new Date() @@ -628,6 +651,50 @@ export async function updateColumnConstraints( }) } +/** + * Updates the option set of a `select`/`multiselect` column without changing its + * type. Existing cell values are left untouched — ids that no longer match an + * option render as a neutral fallback pill until reassigned. + */ +export async function updateColumnOptions( + data: UpdateColumnOptionsData, + requestId: string +): Promise { + return withLockedTable(data.tableId, async (table, trx) => { + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) + if (columnIndex === -1) { + throw new Error(`Column "${data.columnName}" not found`) + } + + const column = schema.columns[columnIndex] + if (column.type !== 'select' && column.type !== 'multiselect') { + throw new Error(`Cannot set options on column "${column.name}" of type "${column.type}"`) + } + + const updatedColumn = { ...column, options: data.options } + const columnValidation = validateColumnDefinition(updatedColumn) + if (!columnValidation.valid) { + throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) + } + + const updatedColumns = schema.columns.map((c, i) => (i === columnIndex ? updatedColumn : c)) + const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } + const now = new Date() + + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, updatedAt: now }) + .where(eq(userTableDefinitions.id, data.tableId)) + + logger.info( + `[${requestId}] Updated options for column "${column.name}" in table ${data.tableId}` + ) + + return { ...table, schema: updatedSchema, updatedAt: now } + }) +} + /** * Checks if a value is compatible with a target column type. */ @@ -640,6 +707,11 @@ function isValueCompatibleWithType( switch (targetType) { case 'string': return true + case 'select': + case 'multiselect': + // Stored values are option-id strings (or arrays of them). Existing data + // is preserved on conversion; unmatched ids render as a fallback pill. + return true case 'number': { if (typeof value === 'number') return Number.isFinite(value) if (typeof value === 'string') { diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index 350d8ceae8e..e61796d9788 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -148,7 +148,38 @@ export function getTablePlanLimits(): TablePlanLimitsByPlan { } } -export const COLUMN_TYPES = ['string', 'number', 'boolean', 'date', 'json'] as const +export const COLUMN_TYPES = [ + 'string', + 'number', + 'boolean', + 'date', + 'json', + 'select', + 'multiselect', +] as const + +/** + * Fixed, theme-aware palette for `select`/`multiselect` options. Each token maps + * 1:1 to a `Badge` color variant so options render through the shared badge + * tokens. Never store raw hex. + */ +export const SELECT_COLORS = [ + 'gray', + 'blue', + 'green', + 'amber', + 'orange', + 'red', + 'purple', + 'pink', + 'teal', + 'cyan', +] as const + +export type SelectColor = (typeof SELECT_COLORS)[number] + +/** Maximum number of options a `select`/`multiselect` column may declare. */ +export const MAX_SELECT_OPTIONS = 100 export const NAME_PATTERN = /^[a-z_][a-z0-9_]*$/i diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 384ca5afb7e..5c719a5c300 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -2,7 +2,7 @@ * Type definitions for user-defined tables. */ -import type { COLUMN_TYPES } from '@/lib/table/constants' +import type { COLUMN_TYPES, SelectColor } from '@/lib/table/constants' export type ColumnValue = string | number | boolean | null | Date export type JsonValue = ColumnValue | JsonValue[] | { [key: string]: JsonValue } @@ -26,6 +26,16 @@ export interface ColumnOption { label: string } +/** + * One choice in a `select`/`multiselect` column. `id` is stable — cell data + * references it, so renaming or recoloring an option never rewrites rows. + */ +export interface SelectOption { + id: string + name: string + color: SelectColor +} + export interface ColumnDefinition { /** * Stable storage key for this column. Row data, metadata, workflow-group @@ -45,6 +55,11 @@ export interface ColumnDefinition { * `row.data[getColumnId(col)]` is populated by the group's per-cell run. */ workflowGroupId?: string + /** + * Declared options for a `select`/`multiselect` column. Cells store option + * ids (a single id for `select`, an array of ids for `multiselect`). + */ + options?: SelectOption[] } /** One group output → one plain column. */ @@ -644,6 +659,14 @@ export interface UpdateColumnTypeData { tableId: string columnName: string newType: (typeof COLUMN_TYPES)[number] + /** Options to set when changing to a `select`/`multiselect` type. */ + options?: SelectOption[] +} + +export interface UpdateColumnOptionsData { + tableId: string + columnName: string + options: SelectOption[] } export interface UpdateColumnConstraintsData { diff --git a/apps/sim/lib/table/validation.test.ts b/apps/sim/lib/table/validation.test.ts new file mode 100644 index 00000000000..92784675e4a --- /dev/null +++ b/apps/sim/lib/table/validation.test.ts @@ -0,0 +1,172 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types' +import { + coerceRowToSchema, + validateColumnDefinition, + validateRowAgainstSchema, +} from '@/lib/table/validation' + +const selectColumn: ColumnDefinition = { + id: 'col_status', + name: 'status', + type: 'select', + options: [ + { id: 'opt_open', name: 'Open', color: 'green' }, + { id: 'opt_closed', name: 'Closed', color: 'red' }, + ], +} + +const multiselectColumn: ColumnDefinition = { + id: 'col_tags', + name: 'tags', + type: 'multiselect', + options: [ + { id: 'opt_a', name: 'Alpha', color: 'blue' }, + { id: 'opt_b', name: 'Beta', color: 'purple' }, + ], +} + +function schemaWith(...columns: ColumnDefinition[]): TableSchema { + return { columns } +} + +describe('validateRowAgainstSchema — select', () => { + it('accepts a value matching an option id', () => { + expect( + validateRowAgainstSchema({ col_status: 'opt_open' }, schemaWith(selectColumn)).valid + ).toBe(true) + }) + + it('rejects a value that is not a declared option id', () => { + expect( + validateRowAgainstSchema({ col_status: 'opt_unknown' }, schemaWith(selectColumn)).valid + ).toBe(false) + }) + + it('rejects a non-string value', () => { + const result = validateRowAgainstSchema( + { col_status: 123 } as unknown as RowData, + schemaWith(selectColumn) + ) + expect(result.valid).toBe(false) + }) +}) + +describe('validateRowAgainstSchema — multiselect', () => { + it('accepts an array of valid option ids', () => { + expect( + validateRowAgainstSchema({ col_tags: ['opt_a', 'opt_b'] }, schemaWith(multiselectColumn)) + .valid + ).toBe(true) + }) + + it('rejects an array containing an unknown id', () => { + expect( + validateRowAgainstSchema({ col_tags: ['opt_a', 'nope'] }, schemaWith(multiselectColumn)).valid + ).toBe(false) + }) + + it('rejects a non-array value', () => { + expect( + validateRowAgainstSchema({ col_tags: 'opt_a' }, schemaWith(multiselectColumn)).valid + ).toBe(false) + }) + + it('rejects an empty array when required', () => { + const result = validateRowAgainstSchema( + { col_tags: [] }, + schemaWith({ ...multiselectColumn, required: true }) + ) + expect(result.valid).toBe(false) + }) +}) + +describe('coerceRowToSchema — select', () => { + it('maps an option name to its id', () => { + const data: RowData = { col_status: 'Open' } + const result = coerceRowToSchema(data, schemaWith(selectColumn)) + expect(result.valid).toBe(true) + expect(data.col_status).toBe('opt_open') + }) + + it('maps an option name case-insensitively', () => { + const data: RowData = { col_status: 'closed' } + coerceRowToSchema(data, schemaWith(selectColumn)) + expect(data.col_status).toBe('opt_closed') + }) + + it('nulls an unmatched value on an optional column', () => { + const data: RowData = { col_status: 'banana' } + const result = coerceRowToSchema(data, schemaWith(selectColumn)) + expect(result.valid).toBe(true) + expect(data.col_status).toBeNull() + }) +}) + +describe('coerceRowToSchema — multiselect', () => { + it('resolves names and drops unmatched entries', () => { + const data: RowData = { col_tags: ['Alpha', 'opt_b', 'ghost'] } + const result = coerceRowToSchema(data, schemaWith(multiselectColumn)) + expect(result.valid).toBe(true) + expect(data.col_tags).toEqual(['opt_a', 'opt_b']) + }) + + it('wraps a single string into a one-element array', () => { + const data: RowData = { col_tags: 'opt_a' as unknown as string[] } + coerceRowToSchema(data, schemaWith(multiselectColumn)) + expect(data.col_tags).toEqual(['opt_a']) + }) +}) + +describe('validateColumnDefinition — select options', () => { + it('accepts a well-formed select column', () => { + expect(validateColumnDefinition(selectColumn).valid).toBe(true) + }) + + it('requires at least one option', () => { + expect(validateColumnDefinition({ ...selectColumn, options: [] }).valid).toBe(false) + }) + + it('rejects duplicate option ids', () => { + const result = validateColumnDefinition({ + ...selectColumn, + options: [ + { id: 'dup', name: 'One', color: 'green' }, + { id: 'dup', name: 'Two', color: 'red' }, + ], + }) + expect(result.valid).toBe(false) + }) + + it('rejects duplicate option names', () => { + const result = validateColumnDefinition({ + ...selectColumn, + options: [ + { id: 'a', name: 'Same', color: 'green' }, + { id: 'b', name: 'same', color: 'red' }, + ], + }) + expect(result.valid).toBe(false) + }) + + it('rejects an invalid color', () => { + const result = validateColumnDefinition({ + ...selectColumn, + options: [{ id: 'a', name: 'One', color: 'chartreuse' as never }], + }) + expect(result.valid).toBe(false) + }) + + it('rejects options on a non-select column', () => { + const result = validateColumnDefinition({ + id: 'c', + name: 'plain', + type: 'string', + options: [{ id: 'a', name: 'One', color: 'green' }], + }) + expect(result.valid).toBe(false) + }) +}) diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index df773160513..103d3f8a052 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -7,7 +7,15 @@ import { userTableRows } from '@sim/db/schema' import { and, eq, or, type SQL, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getColumnId } from '@/lib/table/column-keys' -import { COLUMN_TYPES, getMaxRowSizeBytes, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' +import { + COLUMN_TYPES, + getMaxRowSizeBytes, + MAX_SELECT_OPTIONS, + NAME_PATTERN, + SELECT_COLORS, + type SelectColor, + TABLE_LIMITS, +} from '@/lib/table/constants' import { normalizeDateCellValue } from '@/lib/table/dates' import { withSeqscanOff } from '@/lib/table/planner' import type { @@ -256,12 +264,51 @@ export function validateRowAgainstSchema(data: RowData, schema: TableSchema): Va errors.push(`${column.name} must be valid JSON`) } break + case 'select': + if (typeof value !== 'string' || !optionIds(column).has(value)) { + errors.push(`${column.name} must be one of the defined options`) + } + break + case 'multiselect': { + if (!Array.isArray(value)) { + errors.push(`${column.name} must be a list of options`) + } else { + const ids = optionIds(column) + if (!value.every((v) => typeof v === 'string' && ids.has(v))) { + errors.push(`${column.name} must only contain defined options`) + } else if (column.required && value.length === 0) { + errors.push(`Missing required field: ${column.name}`) + } + } + break + } } } return { valid: errors.length === 0, errors } } +/** Set of valid option ids for a `select`/`multiselect` column. */ +function optionIds(column: ColumnDefinition): Set { + return new Set((column.options ?? []).map((o) => o.id)) +} + +/** + * Resolves a raw cell value to a declared option id, accepting either the + * stable id or (tolerant for tool/import writes) the option's display name. + * Returns null when no option matches. + */ +function resolveOptionId(value: JsonValue, column: ColumnDefinition): string | null { + if (typeof value !== 'string') return null + const options = column.options ?? [] + const byId = options.find((o) => o.id === value) + if (byId) return byId.id + const byName = + options.find((o) => o.name === value) ?? + options.find((o) => o.name.toLowerCase() === value.toLowerCase()) + return byName ? byName.id : null +} + /** * Attempts to coerce a non-null value to a column's declared type. Returns the * coerced value when the value already matches or can be converted without @@ -270,9 +317,9 @@ export function validateRowAgainstSchema(data: RowData, schema: TableSchema): Va */ function coerceValueToColumnType( value: JsonValue, - type: ColumnDefinition['type'] + column: ColumnDefinition ): { ok: true; value: JsonValue } | { ok: false } { - switch (type) { + switch (column.type) { case 'string': if (typeof value === 'string') return { ok: true, value } if (typeof value === 'number' || typeof value === 'boolean') { @@ -310,6 +357,19 @@ function coerceValueToColumnType( if (date && !Number.isNaN(date.getTime())) return { ok: true, value: date.toISOString() } return { ok: false } } + case 'select': { + const id = resolveOptionId(value, column) + return id !== null ? { ok: true, value: id } : { ok: false } + } + case 'multiselect': { + const raw = Array.isArray(value) ? value : [value] + const ids: string[] = [] + for (const entry of raw) { + const id = resolveOptionId(entry, column) + if (id !== null && !ids.includes(id)) ids.push(id) + } + return { ok: true, value: ids } + } default: return { ok: true, value } } @@ -331,7 +391,7 @@ export function coerceRowValues(data: RowData, schema: TableSchema): void { const value = data[key] if (value === null || value === undefined) continue - const coerced = coerceValueToColumnType(value, column.type) + const coerced = coerceValueToColumnType(value, column) if (coerced.ok) { data[key] = coerced.value } else if (!column.required) { @@ -689,5 +749,50 @@ export function validateColumnDefinition(column: ColumnDefinition): ValidationRe ) } + if (column.type === 'select' || column.type === 'multiselect') { + errors.push(...validateSelectOptions(column)) + } else if (column.options !== undefined) { + errors.push(`Column "${column.name}" cannot define options for type "${column.type}"`) + } + return { valid: errors.length === 0, errors } } + +/** Validates the option set declared on a `select`/`multiselect` column. */ +function validateSelectOptions(column: ColumnDefinition): string[] { + const errors: string[] = [] + const options = column.options + if (!Array.isArray(options) || options.length === 0) { + errors.push(`Column "${column.name}" of type "${column.type}" must define at least one option`) + return errors + } + if (options.length > MAX_SELECT_OPTIONS) { + errors.push(`Column "${column.name}" cannot have more than ${MAX_SELECT_OPTIONS} options`) + } + const ids = new Set() + const names = new Set() + const validColors = new Set(SELECT_COLORS) + for (const opt of options) { + if (!opt.id || typeof opt.id !== 'string') { + errors.push(`Column "${column.name}" has an option missing an id`) + } else if (ids.has(opt.id)) { + errors.push(`Column "${column.name}" has duplicate option id "${opt.id}"`) + } else { + ids.add(opt.id) + } + if (!opt.name || typeof opt.name !== 'string') { + errors.push(`Column "${column.name}" has an option missing a name`) + } else { + const key = opt.name.toLowerCase() + if (names.has(key)) { + errors.push(`Column "${column.name}" has duplicate option name "${opt.name}"`) + } else { + names.add(key) + } + } + if (!validColors.has(opt.color)) { + errors.push(`Column "${column.name}" has an option with invalid color "${opt.color}"`) + } + } + return errors +} From d270a0f27174014ed68b7cd6e10f34796fd588a2 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 22 Jul 2026 14:37:31 -0700 Subject: [PATCH 02/20] feat(tables): select/multiselect column UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the new enum column types in the tables grid. - New `select-field/` module: SelectPill (colored option via the shared Badge palette), SelectValueEditor (one ChipDropdown-backed picker reused by every edit surface), SelectOptionsEditor (add/rename/recolor/remove) - Option colors are picked from inline squircle swatches — no labels, no nested dropdown. Each swatch's fill is a Badge in that variant, so the palette stays single-sourced and theme-aware - Cells render option pills; an empty select cell shows a muted "None" so it reads as a dropdown. The single-select menu always offers "None" to clear - Wired into all three edit surfaces (inline cell, expanded popover, row modal) plus the type picker and column-type icons - ChipDropdown gains `defaultOpen`/`onOpenChange` so the inline cell editor can open on mount and commit when the menu closes; open state is now controlled in both single and multi modes Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014e4MvFV1szLhNLap1CCNUd --- .../column-config-sidebar.tsx | 62 +++++++++++- .../column-config-sidebar/column-types.ts | 4 + .../components/row-modal/row-modal.tsx | 9 ++ .../components/select-field/index.ts | 4 + .../components/select-field/select-colors.ts | 19 ++++ .../select-field/select-options-editor.tsx | 98 +++++++++++++++++++ .../components/select-field/select-pill.tsx | 39 ++++++++ .../select-field/select-value-editor.tsx | 85 ++++++++++++++++ .../table-grid/cells/cell-render.tsx | 25 ++++- .../cells/expanded-cell-popover.tsx | 63 +++++++++++- .../table-grid/cells/inline-editors.tsx | 41 ++++++++ .../table-grid/headers/column-type-icon.tsx | 4 + .../chip-dropdown/chip-dropdown.tsx | 29 +++--- 13 files changed, 465 insertions(+), 17 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-colors.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-pill.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-value-editor.tsx 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..9106ed8132d 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' || type === 'multiselect' +} + +function optionsEqual(a: SelectOption[], b: SelectOption[]): boolean { + return JSON.stringify(a) === JSON.stringify(b) +} + /** * Discriminates the two flows the column-config sidebar handles. Workflow * configuration is a separate component (``) so this surface @@ -94,11 +104,27 @@ function ColumnConfigBody({ const [uniqueInput, setUniqueInput] = useState(() => config.mode === 'edit' ? !!existingColumn?.unique : false ) + const [optionsInput, setOptionsInput] = useState(() => + config.mode === 'edit' ? (existingColumn?.options ?? []) : [] + ) 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 +132,19 @@ 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 } : {}), + ...(wantsOptions ? { options: trimmedOptions } : {}), }) toast.success(`Added "${trimmedName}"`) onClose() @@ -123,11 +156,19 @@ function ColumnConfigBody({ const renamed = trimmedName !== (existingColumn?.name ?? config.columnName) const typeChanged = !!existingColumn && existingColumn.type !== typeInput const uniqueChanged = !!existingColumn && !!existingColumn.unique !== uniqueInput + const optionsChanged = + wantsOptions && !optionsEqual(existingColumn?.options ?? [], trimmedOptions) - const updates: { name?: string; type?: ColumnDefinition['type']; unique?: boolean } = { + const updates: { + name?: string + type?: ColumnDefinition['type'] + unique?: boolean + options?: SelectOption[] + } = { ...(renamed ? { name: trimmedName } : {}), ...(typeChanged ? { type: typeInput } : {}), ...(uniqueChanged ? { unique: uniqueInput } : {}), + ...(wantsOptions && (typeChanged || optionsChanged) ? { options: trimmedOptions } : {}), } if (Object.keys(updates).length === 0) { onClose() @@ -207,6 +248,23 @@ function ColumnConfigBody({ )} + {wantsOptions && ( + <> + +
+ Options + { + setOptionsInput(next) + if (optionsError) setOptionsError(null) + }} + /> + {optionsError && } +
+ + )} +
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..eab828b5dc5 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 @@ -1,7 +1,9 @@ import type React from 'react' import { Calendar as CalendarIcon, + ClipboardList, PlayOutline, + TagIcon, TypeBoolean, TypeJson, TypeNumber, @@ -28,6 +30,8 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [ { type: 'boolean', label: 'Boolean', icon: TypeBoolean }, { type: 'date', label: 'Date', icon: CalendarIcon }, { type: 'json', label: 'JSON', icon: TypeJson }, + { type: 'select', label: 'Select', icon: TagIcon }, + { type: 'multiselect', label: 'Multi-select', icon: ClipboardList }, { type: 'workflow', label: 'Workflow', icon: PlayOutline }, ] 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..61a8573b168 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' || column.type === 'multiselect') { + return ( + + + + ) + } + return ( = { + gray: 'Gray', + blue: 'Blue', + green: 'Green', + amber: 'Amber', + orange: 'Orange', + red: 'Red', + purple: 'Purple', + pink: 'Pink', + teal: 'Teal', + cyan: 'Cyan', +} + +/** Palette tokens in display order. */ +export const SELECT_COLOR_ORDER: readonly SelectColor[] = SELECT_COLORS diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx new file mode 100644 index 00000000000..c181d3ad56d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx @@ -0,0 +1,98 @@ +'use client' + +import { Badge, Button, ChipInput, cn } from '@sim/emcn' +import { Plus, Trash } from '@sim/emcn/icons' +import { generateShortId } from '@sim/utils/id' +import type { SelectColor, SelectOption } from '@/lib/table' +import { SELECT_COLOR_LABELS, SELECT_COLOR_ORDER } from './select-colors' + +interface SelectOptionsEditorProps { + options: SelectOption[] + onChange: (options: SelectOption[]) => void +} + +/** Default color for a freshly added option — cycles through the palette. */ +function nextColor(count: number): SelectColor { + return SELECT_COLOR_ORDER[count % SELECT_COLOR_ORDER.length] +} + +interface ColorSwatchesProps { + value: SelectColor + onChange: (color: SelectColor) => void +} + +/** Inline row of color squircles; the active color is ringed. */ +function ColorSwatches({ value, onChange }: ColorSwatchesProps) { + return ( +
+ {SELECT_COLOR_ORDER.map((color) => ( + + ))} +
+ ) +} + +/** + * Add/remove/rename/recolor the options of a `select`/`multiselect` column. + * Option ids are stable across edits so existing cell data survives renames. + */ +export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorProps) { + const update = (id: string, patch: Partial) => { + onChange(options.map((o) => (o.id === id ? { ...o, ...patch } : o))) + } + + const remove = (id: string) => { + onChange(options.filter((o) => o.id !== id)) + } + + const add = () => { + onChange([...options, { id: generateShortId(), name: '', color: nextColor(options.length) }]) + } + + return ( +
+ {options.map((option) => ( +
+
+ update(option.id, { name: e.target.value })} + placeholder='Option name' + spellCheck={false} + autoComplete='off' + className='min-w-0 flex-1' + /> + +
+ update(option.id, { color })} /> +
+ ))} + +
+ ) +} 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..7deced536df --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-pill.tsx @@ -0,0 +1,39 @@ +'use client' + +import { Badge, cn } from '@sim/emcn' +import type { ColumnDefinition, SelectOption } from '@/lib/table' + +/** Reads the selected option ids from a stored cell value of either select type. */ +export function toSelectedIds(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string') + if (typeof value === 'string' && value !== '') return [value] + return [] +} + +/** + * Resolves the stored ids of a `select`/`multiselect` cell to their declared + * options, preserving selection order. An id with no matching option (stale + * after an option was deleted) resolves to a neutral gray fallback so the cell + * never renders blank. + */ +export function resolveSelectOptions(column: ColumnDefinition, value: unknown): SelectOption[] { + const options = column.options ?? [] + return toSelectedIds(value).map( + (id) => options.find((o) => o.id === id) ?? { id, name: id, color: 'gray' } + ) +} + +interface SelectPillProps { + option: SelectOption + size?: 'sm' | 'md' + className?: string +} + +/** A single colored option pill, rendered through the shared `Badge` palette. */ +export function SelectPill({ option, size = 'sm', className }: SelectPillProps) { + return ( + + {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..bf08cc1b3db --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-value-editor.tsx @@ -0,0 +1,85 @@ +'use client' + +import { useMemo } from 'react' +import { ChipDropdown } from '@sim/emcn' +import type { ColumnDefinition } from '@/lib/table' +import { SelectPill, toSelectedIds } 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 + /** Open the menu on mount (inline cell editing). */ + defaultOpen?: boolean + /** Fired whenever the menu opens or closes. */ + onOpenChange?: (open: boolean) => void + fullWidth?: boolean + align?: 'start' | 'center' | 'end' +} + +const CLEAR_VALUE = '' + +/** + * Shared option picker for `select`/`multiselect` cells, used by the inline + * editor, expanded popover, and row modal. Renders each option as its colored + * pill and writes option ids back through `onChange`. + */ +export function SelectValueEditor({ + column, + value, + onChange, + defaultOpen, + onOpenChange, + fullWidth, + align = 'start', +}: SelectValueEditorProps) { + const isMulti = column.type === 'multiselect' + const options = useMemo( + () => + (column.options ?? []).map((option) => ({ + value: option.id, + label: , + })), + [column.options] + ) + + if (isMulti) { + return ( + onChange(ids)} + options={options} + showAllOption={false} + placeholder='Select options' + align={align} + fullWidth={fullWidth} + defaultOpen={defaultOpen} + onOpenChange={onOpenChange} + matchTriggerWidth={false} + /> + ) + } + + // Always offer a "None" entry so any single-select cell can be cleared from + // its own dropdown, regardless of the column's required flag. + const singleOptions = [ + { value: CLEAR_VALUE, label: None }, + ...options, + ] + + return ( + onChange(id === CLEAR_VALUE ? null : id)} + options={singleOptions} + placeholder='Select an option' + align={align} + fullWidth={fullWidth} + defaultOpen={defaultOpen} + onOpenChange={onOpenChange} + matchTriggerWidth={false} + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index edc4a92302e..b0800e7f827 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -5,9 +5,10 @@ import { useEffect, useRef, useState } from 'react' import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn' import { parse } from 'tldts' import { faviconUrl } from '@/lib/core/utils/favicon' -import type { RowExecutionMetadata } from '@/lib/table' +import type { RowExecutionMetadata, SelectOption } from '@/lib/table' import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils' import { storageToDisplay } from '../../../utils' +import { resolveSelectOptions, SelectPill } from '../../select-field' import type { DisplayColumn } from '../types' import { SimResourceCell, type SimResourceType } from './sim-resource-cell' @@ -25,6 +26,7 @@ export type CellRenderKind = | { kind: 'no-output' } // Plain typed cells | { kind: 'boolean'; checked: boolean } + | { kind: 'select'; options: SelectOption[] } | { kind: 'json'; text: string } | { kind: 'date'; text: string } | { kind: 'url'; text: string; href: string; domain: string } @@ -120,6 +122,11 @@ export function resolveCellRender({ } if (column.type === 'boolean') return { kind: 'boolean', checked: Boolean(value) } + // Always render select cells as the `select` kind — an empty one shows a muted + // "None" so every select cell reads as a clickable dropdown. + if (column.type === 'select' || column.type === 'multiselect') { + return { kind: 'select', options: resolveSelectOptions(column, value) } + } if (isNull) return { kind: 'empty' } if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) } if (column.type === 'date') return { kind: 'date', text: String(value) } @@ -346,6 +353,22 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
) + case 'select': + return ( + + {kind.options.length > 0 ? ( + kind.options.map((option) => ) + ) : ( + None + )} + + ) + case 'json': return ( - {isEditable ? ( + {isEditable && isSelectCell ? ( + + ) : isEditable ? ( ) } + +interface ExpandedSelectEditorProps { + initialValue: unknown + column: DisplayColumn + rowId: string + onSave: ExpandedCellPopoverProps['onSave'] + onClose: () => void +} + +/** Select/multiselect body of the expanded popover. */ +function ExpandedSelectEditor({ + initialValue, + column, + rowId, + onSave, + onClose, +}: ExpandedSelectEditorProps) { + const [draft, setDraft] = useState( + column.type === 'multiselect' + ? Array.isArray(initialValue) + ? (initialValue as string[]) + : [] + : typeof initialValue === 'string' && initialValue !== '' + ? initialValue + : null + ) + + const handleSave = () => { + onSave(rowId, column.key, draft, 'blur') + onClose() + } + + return ( + <> +
+ +
+
+
+ + +
+
+ + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx index 20ac51847a2..3ffa350c924 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx @@ -14,6 +14,7 @@ import { storageToDisplay, todayLocalCalendarDate, } from '../../../utils' +import { SelectValueEditor, toSelectedIds } from '../../select-field' interface InlineEditorProps { value: unknown @@ -323,10 +324,50 @@ function InlineTextEditor({ ) } +/** + * Inline editor for `select`/`multiselect` columns — opens the option dropdown + * immediately and commits the chosen ids when the menu closes. + */ +function InlineSelectEditor({ value, column, onSave }: InlineEditorProps) { + const initial: string | string[] | null = + column.type === 'multiselect' ? toSelectedIds(value) : (toSelectedIds(value)[0] ?? null) + const [draft, setDraft] = useState(initial) + const latestRef = useRef(draft) + const doneRef = useRef(false) + + const handleChange = useCallback((next: string | string[] | null) => { + setDraft(next) + latestRef.current = next + }, []) + + const handleOpenChange = useCallback( + (open: boolean) => { + if (open || doneRef.current) return + doneRef.current = true + onSave(latestRef.current, 'enter') + }, + [onSave] + ) + + return ( + + ) +} + /** Dispatches to the right editor variant based on the column type. */ export function InlineEditor(props: InlineEditorProps) { if (props.column.type === 'date') { return } + if (props.column.type === 'select' || props.column.type === 'multiselect') { + return + } return } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx index a1d747c463d..507a2d7dd89 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx @@ -4,7 +4,9 @@ import type React from 'react' import { Tooltip } from '@sim/emcn' import { Calendar as CalendarIcon, + ClipboardList, PlayOutline, + TagIcon, TypeBoolean, TypeJson, TypeNumber, @@ -19,6 +21,8 @@ export const COLUMN_TYPE_ICONS: Record = { boolean: TypeBoolean, date: CalendarIcon, json: TypeJson, + select: TagIcon, + multiselect: ClipboardList, } interface ColumnTypeIconProps { diff --git a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx index 244b09b6585..12b397cd8b6 100644 --- a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx +++ b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx @@ -71,6 +71,10 @@ interface ChipDropdownBaseProps extends VariantProps { leftIcon?: ChipIcon /** Forwarded class for the trigger button. */ className?: string + /** Open the menu on mount (e.g. an inline cell editor that should open immediately). */ + defaultOpen?: boolean + /** Notified whenever the menu opens or closes. */ + onOpenChange?: (open: boolean) => void } /** @@ -168,6 +172,8 @@ const ChipDropdown = forwardRef( active, fullWidth, flush, + defaultOpen, + onOpenChange, } = props const isMultiple = props.multiple === true @@ -185,8 +191,14 @@ const ChipDropdown = forwardRef( */ const insideModal = useContext(InsideModalContext) - const [open, setOpen] = useState(false) + const [open, setOpen] = useState(defaultOpen ?? false) const [search, setSearch] = useState('') + + const handleOpenChange = (next: boolean) => { + setOpen(next) + if (!next) setSearch('') + onOpenChange?.(next) + } const searchable = isMultiple && props.searchable === true const searchPlaceholder = isMultiple ? (props.searchPlaceholder ?? 'Search...') : 'Search...' const allLabel = isMultiple ? (props.allLabel ?? 'All') : '' @@ -259,6 +271,8 @@ const ChipDropdown = forwardRef( ) } else { props.onChange?.(option.value) + setOpen(false) + onOpenChange?.(false) } }} > @@ -270,18 +284,7 @@ const ChipDropdown = forwardRef( } return ( - { - setOpen(next) - if (!next) setSearch('') - }, - } - : {})} - > + - ))} -
- ) -} - /** - * Add/remove/rename/recolor the options of a `select`/`multiselect` column. - * Option ids are stable across edits so existing cell data survives renames. + * Add/remove/rename the options of a `select`/`multiselect` column. Option ids + * are stable across edits so existing cell data survives renames. Options + * default to the neutral `gray` pill — per-option colors are not yet exposed + * (the `color` field stays in the model so a picker can be re-added later). */ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorProps) { const update = (id: string, patch: Partial) => { @@ -60,33 +26,30 @@ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorPr } const add = () => { - onChange([...options, { id: generateShortId(), name: '', color: nextColor(options.length) }]) + onChange([...options, { id: generateShortId(), name: '', color: 'gray' }]) } return ( -
+
{options.map((option) => ( -
-
- update(option.id, { name: e.target.value })} - placeholder='Option name' - spellCheck={false} - autoComplete='off' - className='min-w-0 flex-1' - /> - -
- update(option.id, { color })} /> +
+ update(option.id, { name: e.target.value })} + placeholder='Option name' + spellCheck={false} + autoComplete='off' + className='min-w-0 flex-1' + /> +
))}
))} -
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx index ce515a47ba0..ce99edc0894 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx @@ -12,7 +12,6 @@ import { formatValueForInput, storageToDisplay, } from '../../../utils' -import { SelectValueEditor, toSelectedIds } from '../../select-field' import type { DisplayColumn } from '../types' interface ExpandedCellPopoverProps { @@ -63,7 +62,6 @@ export function ExpandedCellPopover({ }, [expandedCell, rows, columns]) const isBooleanCell = target?.column.type === 'boolean' - const isSelectCell = target?.column.type === 'select' || target?.column.type === 'multiselect' // Workflow-output cells are editable in the expanded view too — the user // can override the workflow's value. Booleans toggle inline; the expanded // popover only handles text-shaped inputs. @@ -150,16 +148,7 @@ export function ExpandedCellPopover({ className='fixed z-50 flex flex-col overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--bg)] shadow-md' style={{ top, left, width, height: EXPANDED_CELL_HEIGHT }} > - {isEditable && isSelectCell ? ( - - ) : isEditable ? ( + {isEditable ? ( ) } - -interface ExpandedSelectEditorProps { - initialValue: unknown - column: DisplayColumn - rowId: string - onSave: ExpandedCellPopoverProps['onSave'] - onClose: () => void -} - -/** Select/multiselect body of the expanded popover. */ -function ExpandedSelectEditor({ - initialValue, - column, - rowId, - onSave, - onClose, -}: ExpandedSelectEditorProps) { - // `toSelectedIds` normalizes both shapes — a single id string (normal right - // after a select→multiselect conversion, before the row is rewritten) and an - // array — so opening the popover never collapses a stored value to empty. - const [draft, setDraft] = useState( - column.type === 'multiselect' - ? toSelectedIds(initialValue) - : (toSelectedIds(initialValue)[0] ?? null) - ) - - const handleSave = () => { - onSave(rowId, column.key, draft, 'blur') - onClose() - } - - return ( - <> -
- -
-
-
- - -
-
- - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 2082cd0353f..517f6e52ae0 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -2029,6 +2029,16 @@ export function TableGrid({ return } + // Select/multiselect: open the inline option dropdown when editable; never + // the big text popover. Read-only cells do nothing (like booleans). + if (column?.type === 'select' || column?.type === 'multiselect') { + if (canEditRef.current) { + setEditingCell({ rowId, columnName }) + setInitialCharacter(null) + } + return + } + // Workflow-output cell with no value → let the user write over the status pill. if (column?.workflowGroupId && canEditRef.current) { const row = rowsRef.current.find((r) => r.id === rowId) From 06466649f35f8b18f948dacb068117147c799757 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Jul 2026 11:06:04 -0700 Subject: [PATCH 09/20] improvement(tables): inline select cell uses bare DropdownMenu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline select editor used a ChipDropdown pill, which reads as a foreign form control inside a grid cell. It now renders the canonical DropdownMenu anchored to the cell — an invisible full-cell trigger (no pill, no label text), options as their colored pills with a check on the selected ones, and "None" as a proper menu item. The cell keeps showing its pills while the menu is open instead of going blank. SelectValueEditor (the ChipDropdown pill) is now used only by the row modal, where a pill form control is idiomatic. This lets the defaultOpen/onOpenChange additions to the shared ChipDropdown be reverted — it's back to its original behavior with no consumers depending on the change. --- .../select-field/select-value-editor.tsx | 17 +-- .../table-grid/cells/cell-render.tsx | 10 +- .../table-grid/cells/inline-editors.tsx | 113 +++++++++++++----- .../chip-dropdown/chip-dropdown.tsx | 29 ++--- 4 files changed, 103 insertions(+), 66 deletions(-) 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 index dcfffbc2ea3..b742023aae0 100644 --- 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 @@ -10,10 +10,6 @@ interface SelectValueEditorProps { value: unknown /** Single columns emit a string id or null; multiselect emits a string[]. */ onChange: (next: string | string[] | null) => void - /** Open the menu on mount (inline cell editing). */ - defaultOpen?: boolean - /** Fired whenever the menu opens or closes. */ - onOpenChange?: (open: boolean) => void fullWidth?: boolean align?: 'start' | 'center' | 'end' } @@ -21,16 +17,15 @@ interface SelectValueEditorProps { const CLEAR_VALUE = '' /** - * Shared option picker for `select`/`multiselect` cells, used by the inline - * editor, expanded popover, and row modal. Renders each option as its colored - * pill and writes option ids back through `onChange`. + * 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, - defaultOpen, - onOpenChange, fullWidth, align = 'start', }: SelectValueEditorProps) { @@ -60,8 +55,6 @@ export function SelectValueEditor({ placeholder='Select options' align={align} fullWidth={fullWidth} - defaultOpen={defaultOpen} - onOpenChange={onOpenChange} matchTriggerWidth={false} /> ) @@ -84,8 +77,6 @@ export function SelectValueEditor({ placeholder='Select an option' align={align} fullWidth={fullWidth} - defaultOpen={defaultOpen} - onOpenChange={onOpenChange} matchTriggerWidth={false} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index b0800e7f827..5ad4d53553f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -354,13 +354,11 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle ) case 'select': + // 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 rather than going blank. return ( - + {kind.options.length > 0 ? ( kind.options.map((option) => ) ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx index fd7d330dfc5..745368b330f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx @@ -1,7 +1,19 @@ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' -import { Calendar, cn, Popover, PopoverAnchor, PopoverContent, toast } from '@sim/emcn' +import { + Calendar, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Popover, + PopoverAnchor, + PopoverContent, + toast, +} from '@sim/emcn' +import { Check } from '@sim/emcn/icons' import type { ColumnDefinition } from '@/lib/table' import { isCalendarDateString } from '@/lib/table/dates' import { useTimezone } from '@/hooks/queries/general-settings' @@ -14,7 +26,7 @@ import { storageToDisplay, todayLocalCalendarDate, } from '../../../utils' -import { SelectValueEditor, toSelectedIds } from '../../select-field' +import { SelectPill, toSelectedIds } from '../../select-field' interface InlineEditorProps { value: unknown @@ -325,20 +337,39 @@ function InlineTextEditor({ } /** - * Inline editor for `select`/`multiselect` columns — opens the option dropdown - * immediately and commits the chosen ids when the menu closes. Escape discards - * the draft (matching the text/date inline editors) rather than committing it. + * Inline editor for `select`/`multiselect` columns. Renders the canonical + * `DropdownMenu` anchored to the cell (an invisible full-cell trigger, no pill + * chrome) and opens it immediately. Single-select commits on pick; multiselect + * toggles and commits when the menu closes. Escape discards the draft, matching + * the text/date inline editors. */ function InlineSelectEditor({ value, column, onSave, onCancel }: InlineEditorProps) { - const initial: string | string[] | null = - column.type === 'multiselect' ? toSelectedIds(value) : (toSelectedIds(value)[0] ?? null) - const [draft, setDraft] = useState(initial) + const isMulti = column.type === 'multiselect' + const allOptions = column.options ?? [] + const [draft, setDraft] = useState(() => toSelectedIds(value)) + const [open, setOpen] = useState(true) const latestRef = useRef(draft) const doneRef = useRef(false) const cancelledRef = useRef(false) - // Escape closes the Radix menu (firing `onOpenChange(false)`), which would - // otherwise commit. Capture it first so the close handler cancels instead. + 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 @@ -347,30 +378,50 @@ function InlineSelectEditor({ value, column, onSave, onCancel }: InlineEditorPro return () => document.removeEventListener('keydown', onKeyDown, true) }, []) - const handleChange = useCallback((next: string | string[] | null) => { - setDraft(next) - latestRef.current = next - }, []) + const handleOpenChange = (next: boolean) => { + setOpen(next) + if (!next) commit() + } - const handleOpenChange = useCallback( - (open: boolean) => { - if (open || doneRef.current) return - doneRef.current = true - if (cancelledRef.current) onCancel() - else onSave(latestRef.current, 'enter') - }, - [onSave, onCancel] - ) + 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 ( - + + +
+ +
+ + setMultipleInput(!!v)} + /> +
)} - -
-
- - setUniqueInput(!!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 eab828b5dc5..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 @@ -1,7 +1,6 @@ import type React from 'react' import { Calendar as CalendarIcon, - ClipboardList, PlayOutline, TagIcon, TypeBoolean, @@ -31,7 +30,6 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [ { type: 'date', label: 'Date', icon: CalendarIcon }, { type: 'json', label: 'JSON', icon: TypeJson }, { type: 'select', label: 'Select', icon: TagIcon }, - { type: 'multiselect', label: 'Multi-select', icon: ClipboardList }, { type: 'workflow', label: 'Workflow', icon: PlayOutline }, ] 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 61a8573b168..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 @@ -276,7 +276,7 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) { ) } - if (column.type === 'select' || column.type === 'multiselect') { + if (column.type === 'select') { return ( 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 index b742023aae0..fef56f7de0d 100644 --- 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 @@ -29,7 +29,7 @@ export function SelectValueEditor({ fullWidth, align = 'start', }: SelectValueEditorProps) { - const isMulti = column.type === 'multiselect' + const isMulti = !!column.multiple const options = useMemo( () => (column.options ?? []).map((option) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index 5ad4d53553f..133f8bb2c58 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -2,7 +2,8 @@ import type React from 'react' import { useEffect, useRef, useState } from 'react' -import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn' +import { Badge, Checkbox, cn, comboboxVariants, Tooltip } from '@sim/emcn' +import { ChevronDown } from '@sim/emcn/icons' import { parse } from 'tldts' import { faviconUrl } from '@/lib/core/utils/favicon' import type { RowExecutionMetadata, SelectOption } from '@/lib/table' @@ -124,7 +125,7 @@ export function resolveCellRender({ if (column.type === 'boolean') return { kind: 'boolean', checked: Boolean(value) } // Always render select cells as the `select` kind — an empty one shows a muted // "None" so every select cell reads as a clickable dropdown. - if (column.type === 'select' || column.type === 'multiselect') { + if (column.type === 'select') { return { kind: 'select', options: resolveSelectOptions(column, value) } } if (isNull) return { kind: 'empty' } @@ -354,17 +355,31 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle ) case 'select': - // 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 rather than going blank. + // Render the combobox chrome (border + chevron) so the cell reads as a + // dropdown, reusing emcn's `comboboxVariants` — compacted to the row's + // 22px height. Pills stay visible while editing (the inline editor overlays + // an invisible trigger and portals its menu below); the chevron flips open. return ( - - {kind.options.length > 0 ? ( - kind.options.map((option) => ) - ) : ( - None +
+ > + + {kind.options.length > 0 ? ( + kind.options.map((option) => ) + ) : ( + None + )} + + +
) case 'json': diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx index 745368b330f..05162093449 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx @@ -344,7 +344,7 @@ function InlineTextEditor({ * the text/date inline editors. */ function InlineSelectEditor({ value, column, onSave, onCancel }: InlineEditorProps) { - const isMulti = column.type === 'multiselect' + const isMulti = !!column.multiple const allOptions = column.options ?? [] const [draft, setDraft] = useState(() => toSelectedIds(value)) const [open, setOpen] = useState(true) @@ -430,7 +430,7 @@ export function InlineEditor(props: InlineEditorProps) { if (props.column.type === 'date') { return } - if (props.column.type === 'select' || props.column.type === 'multiselect') { + if (props.column.type === 'select') { return } return diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx index 507a2d7dd89..dfaf662fbf6 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx @@ -4,7 +4,6 @@ import type React from 'react' import { Tooltip } from '@sim/emcn' import { Calendar as CalendarIcon, - ClipboardList, PlayOutline, TagIcon, TypeBoolean, @@ -22,7 +21,6 @@ export const COLUMN_TYPE_ICONS: Record = { date: CalendarIcon, json: TypeJson, select: TagIcon, - multiselect: ClipboardList, } interface ColumnTypeIconProps { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 517f6e52ae0..4f685959c2f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -1447,7 +1447,7 @@ export function TableGrid({ } } else if (column.type === 'date') { text = storageToDisplay(String(val), { seconds: true }) - } else if (column.type === 'select' || column.type === 'multiselect') { + } else if (column.type === 'select') { // Cells store option ids; measure the rendered pill labels instead so // auto-fit doesn't size the column to opaque ids. text = resolveSelectOptions(column, val) @@ -2029,9 +2029,9 @@ export function TableGrid({ return } - // Select/multiselect: open the inline option dropdown when editable; never - // the big text popover. Read-only cells do nothing (like booleans). - if (column?.type === 'select' || column?.type === 'multiselect') { + // Select: open the inline option dropdown when editable; never the big + // text popover. Read-only cells do nothing (like booleans). + if (column?.type === 'select') { if (canEditRef.current) { setEditingCell({ rowId, columnName }) setInitialCharacter(null) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 59dd48385d6..b775b9c92c8 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -30,10 +30,10 @@ export const domainObjectSchema = () => z.custom(isRecordLike) */ export const columnTypeSchema = z.enum(COLUMN_TYPES) -/** Fixed palette token for a `select`/`multiselect` option. */ +/** Fixed palette token for a `select` option. */ export const selectColorSchema = z.enum(SELECT_COLORS) -/** One choice in a `select`/`multiselect` column. `id` is the stable cell key. */ +/** One choice in a `select` column. `id` is the stable cell key. */ export const selectOptionSchema = z.object({ id: z.string().min(1, 'Option id is required'), name: z @@ -48,16 +48,15 @@ export const selectOptionsSchema = z .max(MAX_SELECT_OPTIONS, `A select column cannot have more than ${MAX_SELECT_OPTIONS} options`) /** - * Cross-field rule: `select`/`multiselect` columns must declare a non-empty - * option set; other types must not carry options. Skipped when `type` is absent - * (an options-only update on an existing select column). + * Cross-field rule: a `select` column must declare a non-empty option set; + * other types must not carry options. Skipped when `type` is absent (an + * options-only update on an existing select column). */ function refineColumnOptions( data: { type?: (typeof COLUMN_TYPES)[number]; options?: z.infer }, ctx: z.RefinementCtx ): void { - const isSelect = data.type === 'select' || data.type === 'multiselect' - if (isSelect) { + if (data.type === 'select') { if (!data.options || data.options.length === 0) { ctx.addIssue({ code: 'custom', @@ -69,7 +68,7 @@ function refineColumnOptions( ctx.addIssue({ code: 'custom', path: ['options'], - message: 'options are only allowed on select or multiselect columns', + message: 'options are only allowed on select columns', }) } } @@ -138,8 +137,10 @@ export const tableColumnSchema = z unique: z.boolean().optional().default(false), /** Set when the column is a workflow group's output. */ workflowGroupId: z.string().optional(), - /** Declared options for a `select`/`multiselect` column. */ + /** Declared options for a `select` column. */ options: selectOptionsSchema.optional(), + /** A `select` column that accepts multiple options per cell. */ + multiple: z.boolean().optional(), }) .superRefine(refineColumnOptions) @@ -177,6 +178,7 @@ export const createTableColumnBodySchema = z.object({ unique: z.boolean().optional(), position: z.number().int().min(0).optional(), options: selectOptionsSchema.optional(), + multiple: z.boolean().optional(), }) .superRefine(refineColumnOptions), }) @@ -191,6 +193,7 @@ export const updateTableColumnBodySchema = z.object({ required: z.boolean().optional(), unique: z.boolean().optional(), options: selectOptionsSchema.optional(), + multiple: z.boolean().optional(), }) .superRefine(refineColumnOptions), }) diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 963886c8713..0525a1084fb 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -55,6 +55,7 @@ export async function addTableColumn( unique?: boolean position?: number options?: SelectOption[] + multiple?: boolean }, requestId: string ): Promise { @@ -97,6 +98,7 @@ export async function addTableColumn( required: column.required ?? false, unique: column.unique ?? false, ...(column.options ? { options: column.options } : {}), + ...(column.multiple ? { multiple: true } : {}), } const columnValidation = validateColumnDefinition(newColumn) @@ -514,9 +516,11 @@ export async function updateColumnType( ) ) - // Options the column will carry after the change — a `select`/`multiselect` - // value is only compatible if it resolves against this set. + // Options the column will carry after the change — a `select` value is only + // compatible if it resolves against this set. + const isSelectType = data.newType === 'select' const targetOptions = data.options ?? column.options ?? [] + const targetMultiple = data.multiple ?? column.multiple let incompatibleCount = 0 for (const row of rows) { @@ -535,13 +539,17 @@ export async function updateColumnType( ) } - const isSelectType = data.newType === 'select' || data.newType === 'multiselect' const updatedColumns = schema.columns.map((c, i) => { if (i !== columnIndex) return c - // Drop any prior options, then re-add when the target type uses them. - const { options: _prevOptions, ...rest } = c + // Drop any prior select config, then re-add when the target type uses it. + const { options: _prevOptions, multiple: _prevMultiple, ...rest } = c return isSelectType - ? { ...rest, type: data.newType, options: data.options ?? c.options } + ? { + ...rest, + type: data.newType, + options: data.options ?? c.options, + ...(targetMultiple ? { multiple: true } : {}), + } : { ...rest, type: data.newType } }) @@ -657,9 +665,10 @@ export async function updateColumnConstraints( } /** - * Updates the option set of a `select`/`multiselect` column without changing its - * type. Existing cell values are left untouched — ids that no longer match an - * option render as a neutral fallback pill until reassigned. + * Updates the option set (and optional single/multi mode) of a `select` column + * without changing its type. Existing cell values are left untouched — ids that + * no longer match an option render as a neutral fallback pill until reassigned; + * a single↔multi toggle is reconciled lazily on the next row write. */ export async function updateColumnOptions( data: UpdateColumnOptionsData, @@ -673,11 +682,16 @@ export async function updateColumnOptions( } const column = schema.columns[columnIndex] - if (column.type !== 'select' && column.type !== 'multiselect') { + if (column.type !== 'select') { throw new Error(`Cannot set options on column "${column.name}" of type "${column.type}"`) } - const updatedColumn = { ...column, options: data.options } + const { multiple: _prevMultiple, ...columnRest } = column + const updatedColumn = { + ...columnRest, + options: data.options, + ...((data.multiple ?? column.multiple) ? { multiple: true } : {}), + } const columnValidation = validateColumnDefinition(updatedColumn) if (!columnValidation.valid) { throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) @@ -701,11 +715,11 @@ export async function updateColumnOptions( } /** - * Checks if a value is compatible with a target column type. For - * `select`/`multiselect`, `targetOptions` is the option set the column will - * carry after the change: a value is compatible only if every part of it - * resolves to one of those options (by id or name). This blocks a conversion - * that would otherwise strand or silently drop values on the next row write. + * Checks if a value is compatible with a target column type. For `select`, + * `targetOptions` is the option set the column will carry after the change: a + * value is compatible only if every part of it resolves to one of those options + * (by id or name). This blocks a conversion that would otherwise strand or + * silently drop values on the next row write. */ function isValueCompatibleWithType( value: unknown, @@ -717,9 +731,8 @@ function isValueCompatibleWithType( switch (targetType) { case 'string': return true - case 'select': - return resolveSelectOptionId(value as JsonValue, targetOptions) !== null - case 'multiselect': { + case 'select': { + // Single or multi, every part of the value must resolve to an option. const parts = Array.isArray(value) ? value : [value] return parts.every((v) => resolveSelectOptionId(v as JsonValue, targetOptions) !== null) } diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index e61796d9788..7c1125f524b 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -148,15 +148,7 @@ export function getTablePlanLimits(): TablePlanLimitsByPlan { } } -export const COLUMN_TYPES = [ - 'string', - 'number', - 'boolean', - 'date', - 'json', - 'select', - 'multiselect', -] as const +export const COLUMN_TYPES = ['string', 'number', 'boolean', 'date', 'json', 'select'] as const /** * Fixed, theme-aware palette for `select`/`multiselect` options. Each token maps diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 5c719a5c300..836f27abbb4 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -56,10 +56,12 @@ export interface ColumnDefinition { */ workflowGroupId?: string /** - * Declared options for a `select`/`multiselect` column. Cells store option - * ids (a single id for `select`, an array of ids for `multiselect`). + * Declared options for a `select` column. Cells store option ids — a single + * id when `multiple` is falsy, an array of ids when `multiple` is true. */ options?: SelectOption[] + /** When true, a `select` column accepts several options per cell (string[]). */ + multiple?: boolean } /** One group output → one plain column. */ @@ -659,14 +661,18 @@ export interface UpdateColumnTypeData { tableId: string columnName: string newType: (typeof COLUMN_TYPES)[number] - /** Options to set when changing to a `select`/`multiselect` type. */ + /** Options to set when changing to a `select` type. */ options?: SelectOption[] + /** Whether the `select` column accepts multiple options per cell. */ + multiple?: boolean } export interface UpdateColumnOptionsData { tableId: string columnName: string options: SelectOption[] + /** Toggle single/multi selection alongside the options update. */ + multiple?: boolean } export interface UpdateColumnConstraintsData { diff --git a/apps/sim/lib/table/validation.test.ts b/apps/sim/lib/table/validation.test.ts index 603d5f7e717..a9bce71017f 100644 --- a/apps/sim/lib/table/validation.test.ts +++ b/apps/sim/lib/table/validation.test.ts @@ -23,7 +23,8 @@ const selectColumn: ColumnDefinition = { const multiselectColumn: ColumnDefinition = { id: 'col_tags', name: 'tags', - type: 'multiselect', + type: 'select', + multiple: true, options: [ { id: 'opt_a', name: 'Alpha', color: 'blue' }, { id: 'opt_b', name: 'Beta', color: 'purple' }, diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index c79adfb1e41..49b99a9208a 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -265,21 +265,18 @@ export function validateRowAgainstSchema(data: RowData, schema: TableSchema): Va errors.push(`${column.name} must be valid JSON`) } break - case 'select': - if (typeof value !== 'string' || !optionIds(column).has(value)) { - errors.push(`${column.name} must be one of the defined options`) - } - break - case 'multiselect': { - if (!Array.isArray(value)) { - errors.push(`${column.name} must be a list of options`) - } else { - const ids = optionIds(column) - if (!value.every((v) => typeof v === 'string' && ids.has(v))) { + case 'select': { + const ids = optionIds(column) + if (column.multiple) { + if (!Array.isArray(value)) { + errors.push(`${column.name} must be a list of options`) + } else if (!value.every((v) => typeof v === 'string' && ids.has(v))) { errors.push(`${column.name} must only contain defined options`) } else if (column.required && value.length === 0) { errors.push(`Missing required field: ${column.name}`) } + } else if (typeof value !== 'string' || !ids.has(value)) { + errors.push(`${column.name} must be one of the defined options`) } break } @@ -360,17 +357,21 @@ function coerceValueToColumnType( return { ok: false } } case 'select': { - const id = resolveSelectOptionId(value, column.options ?? []) - return id !== null ? { ok: true, value: id } : { ok: false } - } - case 'multiselect': { - const raw = Array.isArray(value) ? value : [value] - const ids: string[] = [] - for (const entry of raw) { - const id = resolveSelectOptionId(entry, column.options ?? []) - if (id !== null && !ids.includes(id)) ids.push(id) + const options = column.options ?? [] + if (column.multiple) { + const raw = Array.isArray(value) ? value : [value] + const ids: string[] = [] + for (const entry of raw) { + const id = resolveSelectOptionId(entry, options) + if (id !== null && !ids.includes(id)) ids.push(id) + } + return { ok: true, value: ids } } - return { ok: true, value: ids } + // Single: tolerate an array (e.g. right after a multiple→single toggle) by + // resolving its first element so the value isn't dropped wholesale. + const single = Array.isArray(value) ? value[0] : value + const id = single === undefined ? null : resolveSelectOptionId(single, options) + return id !== null ? { ok: true, value: id } : { ok: false } } default: return { ok: true, value } @@ -751,7 +752,7 @@ export function validateColumnDefinition(column: ColumnDefinition): ValidationRe ) } - if (column.type === 'select' || column.type === 'multiselect') { + if (column.type === 'select') { errors.push(...validateSelectOptions(column)) } else if (column.options !== undefined) { errors.push(`Column "${column.name}" cannot define options for type "${column.type}"`) @@ -760,7 +761,7 @@ export function validateColumnDefinition(column: ColumnDefinition): ValidationRe return { valid: errors.length === 0, errors } } -/** Validates the option set declared on a `select`/`multiselect` column. */ +/** Validates the option set declared on a `select` column. */ function validateSelectOptions(column: ColumnDefinition): string[] { const errors: string[] = [] const options = column.options diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index b9b3d37165c..a6840bec6c7 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -93,6 +93,7 @@ export { Combobox, type ComboboxOption, type ComboboxOptionGroup, + comboboxVariants, } from './combobox/combobox' export { DropdownMenu, From d55f94ba590f1439301a749e19fc6978c18fe931 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Jul 2026 13:48:47 -0700 Subject: [PATCH 11/20] improvement(tables): revert select cell to chip-only view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the combobox chrome (border + chevron) from the select cell — the plain option pills read better. Reverts the comboboxVariants barrel export too, since nothing else uses it. --- .../table-grid/cells/cell-render.tsx | 35 ++++++------------- packages/emcn/src/components/index.ts | 1 - 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index 133f8bb2c58..283dfa21d8c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -2,8 +2,7 @@ import type React from 'react' import { useEffect, useRef, useState } from 'react' -import { Badge, Checkbox, cn, comboboxVariants, Tooltip } from '@sim/emcn' -import { ChevronDown } from '@sim/emcn/icons' +import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn' import { parse } from 'tldts' import { faviconUrl } from '@/lib/core/utils/favicon' import type { RowExecutionMetadata, SelectOption } from '@/lib/table' @@ -355,31 +354,17 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle ) case 'select': - // Render the combobox chrome (border + chevron) so the cell reads as a - // dropdown, reusing emcn's `comboboxVariants` — compacted to the row's - // 22px height. Pills stay visible while editing (the inline editor overlays - // an invisible trigger and portals its menu below); the chevron flips open. + // 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 )} - > - - {kind.options.length > 0 ? ( - kind.options.map((option) => ) - ) : ( - None - )} - - -
+
) case 'json': diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index a6840bec6c7..b9b3d37165c 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -93,7 +93,6 @@ export { Combobox, type ComboboxOption, type ComboboxOptionGroup, - comboboxVariants, } from './combobox/combobox' export { DropdownMenu, From 60b15ccd3f60107afbcc96bba33ff341f9aa9b53 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Jul 2026 14:02:12 -0700 Subject: [PATCH 12/20] =?UTF-8?q?fix(tables):=20block=20multiple=E2=86=92s?= =?UTF-8?q?ingle=20select=20switch=20when=20cells=20have=20>1=20option?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching a select column from multiple to single would silently keep only the first option of any multi-valued cell. updateColumnOptions now scans the rows on that transition and errors ("N row(s) have multiple options selected") instead of dropping data, mirroring the type-change compatibility guard. --- apps/sim/lib/table/columns/service.ts | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 0525a1084fb..13de7c358b1 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -686,6 +686,38 @@ export async function updateColumnOptions( throw new Error(`Cannot set options on column "${column.name}" of type "${column.type}"`) } + // Switching multiple → single would silently drop all but the first option + // in any multi-valued cell — block it instead, mirroring the type-change + // compatibility guard. + const willBeMultiple = data.multiple ?? column.multiple + if (column.multiple && !willBeMultiple) { + const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { + baseMs: 60_000, + perRowMs: 2, + }) + await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) + + const columnKey = getColumnId(column) + const rows = await trx + .select({ data: userTableRows.data }) + .from(userTableRows) + .where( + and(eq(userTableRows.tableId, data.tableId), sql`${userTableRows.data} ? ${columnKey}`) + ) + + let multiValuedCount = 0 + for (const row of rows) { + const value = (row.data as RowData)[columnKey] + if (Array.isArray(value) && value.length > 1) multiValuedCount++ + } + + if (multiValuedCount > 0) { + throw new Error( + `Cannot switch column "${column.name}" to single-select: ${multiValuedCount} row(s) have multiple options selected. Reduce them to one option first.` + ) + } + } + const { multiple: _prevMultiple, ...columnRest } = column const updatedColumn = { ...columnRest, From d06c399fc60749e635c447ecb0634302d0284148 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Jul 2026 14:44:07 -0700 Subject: [PATCH 13/20] improvement(tables): rename select "Allow multiple" toggle to "Multiselect" --- .../components/column-config-sidebar/column-config-sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3aad51c6721..be77db90ad6 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 @@ -273,7 +273,7 @@ function ColumnConfigBody({
- + Date: Thu, 23 Jul 2026 15:34:45 -0700 Subject: [PATCH 14/20] fix(tables): drop removed select options from cells instead of stale pills When an option is deleted, cells that referenced it previously rendered a gray fallback pill labeled with the raw internal id. They now drop the orphaned id and fall back to empty ("None"). Editors seed their selection from the still- valid ids too, so editing/saving such a cell writes the cleaned value. --- .../components/select-field/index.ts | 2 +- .../components/select-field/select-pill.tsx | 23 +++++++++++-------- .../select-field/select-value-editor.tsx | 6 ++--- .../table-grid/cells/inline-editors.tsx | 4 ++-- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/index.ts index 04c72fef63f..274f18de92c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/index.ts @@ -1,3 +1,3 @@ export { SelectOptionsEditor } from './select-options-editor' -export { resolveSelectOptions, SelectPill, toSelectedIds } from './select-pill' +export { resolveSelectOptions, SelectPill, selectedOptionIds, toSelectedIds } from './select-pill' export { SelectValueEditor } from './select-value-editor' 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 index 7deced536df..4b76b0949e9 100644 --- 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 @@ -3,7 +3,7 @@ import { Badge, cn } from '@sim/emcn' import type { ColumnDefinition, SelectOption } from '@/lib/table' -/** Reads the selected option ids from a stored cell value of either select type. */ +/** 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] @@ -11,16 +11,21 @@ export function toSelectedIds(value: unknown): string[] { } /** - * Resolves the stored ids of a `select`/`multiselect` cell to their declared - * options, preserving selection order. An id with no matching option (stale - * after an option was deleted) resolves to a neutral gray fallback so the cell - * never renders blank. + * 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 options = column.options ?? [] - return toSelectedIds(value).map( - (id) => options.find((o) => o.id === id) ?? { id, name: id, color: 'gray' } - ) + 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 { 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 index fef56f7de0d..0a22baabcee 100644 --- 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 @@ -3,7 +3,7 @@ import { useMemo } from 'react' import { ChipDropdown } from '@sim/emcn' import type { ColumnDefinition } from '@/lib/table' -import { SelectPill, toSelectedIds } from './select-pill' +import { SelectPill, selectedOptionIds } from './select-pill' interface SelectValueEditorProps { column: ColumnDefinition @@ -43,7 +43,7 @@ export function SelectValueEditor({ return ( { @@ -71,7 +71,7 @@ export function SelectValueEditor({ return ( onChange(id === CLEAR_VALUE ? null : id)} options={singleOptions} placeholder='Select an option' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx index 05162093449..e3caa259fe8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx @@ -26,7 +26,7 @@ import { storageToDisplay, todayLocalCalendarDate, } from '../../../utils' -import { SelectPill, toSelectedIds } from '../../select-field' +import { SelectPill, selectedOptionIds } from '../../select-field' interface InlineEditorProps { value: unknown @@ -346,7 +346,7 @@ function InlineTextEditor({ function InlineSelectEditor({ value, column, onSave, onCancel }: InlineEditorProps) { const isMulti = !!column.multiple const allOptions = column.options ?? [] - const [draft, setDraft] = useState(() => toSelectedIds(value)) + const [draft, setDraft] = useState(() => selectedOptionIds(column, value)) const [open, setOpen] = useState(true) const latestRef = useRef(draft) const doneRef = useRef(false) From 1138baad22f705cdeaa524c504707e448a0971a1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Jul 2026 16:30:25 -0700 Subject: [PATCH 15/20] improvement(tables): use dashed add-row button for select options Match the sub-block list editors (filter/sort builders): a full-width, dashed-border ghost "Add option" button instead of a plain text button. --- .../components/select-field/select-options-editor.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx index c8112e0b90d..c562be01aa8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx @@ -54,11 +54,10 @@ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorPr ))}
From 7cd6f776b46d037650a856e6bad667ae69d40f01 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Jul 2026 16:48:17 -0700 Subject: [PATCH 16/20] perf(tables): don't refetch rows on metadata-only column saves; fix copy log - Add/update column are metadata-only server-side (row data never changes), so they now invalidate just the schema, not the rows. Saving a column (e.g. editing select options) no longer triggers a full rows refetch/flash; cells re-render from the refetched schema. Delete-column and workflow-group ops keep the rows invalidation since they do change row data. - "Failed to copy rows" logged an empty {} for DOMExceptions; log the extracted message so the real reason (e.g. lost transient activation) is visible. --- .../components/table-grid/table-grid.tsx | 6 ++++-- apps/sim/hooks/queries/tables.ts | 19 +++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 4f685959c2f..7200e24039f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { cn, toast, useToast } from '@sim/emcn' import { Loader, TableX } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { useVirtualizer } from '@tanstack/react-virtual' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' @@ -2525,8 +2526,9 @@ export function TableGrid({ } catch (error) { // Rejects if the row load failed or the payload is too large for the // clipboard — either way nothing landed, so report a plain failure - // rather than implying a size cap was hit. - logger.error(`Failed to ${verbLower} rows`, { error }) + // rather than implying a size cap was hit. Log the message explicitly: + // a DOMException (e.g. lost transient activation) serializes to `{}`. + logger.error(`Failed to ${verbLower} rows`, { error: getErrorMessage(error) }) dismissToastRef.current(loadingToastId) toast.error(`Failed to ${verbLower} — please try again`) return diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 7510875ebbf..2cf4f22ea5b 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -212,6 +212,21 @@ function invalidateTableSchema(queryClient: ReturnType, t queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) } +/** + * Invalidate only the schema, not the rows. Column-definition changes + * (add/rename/type/options/constraints) are metadata-only server-side — the + * stored row data never changes — so cells re-render from the refetched schema + * without a (potentially large) rows refetch. Deleting a column strips keys from + * row data, so it must use {@link invalidateTableSchema} instead. + */ +function invalidateTableSchemaOnly( + queryClient: ReturnType, + tableId: string +) { + queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) }) + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) +} + /** * Fetch all tables for a workspace. */ @@ -533,7 +548,7 @@ export function useAddTableColumn({ workspaceId, tableId }: RowMutationContext) toast.error(error.message, { duration: 5000 }) }, onSettled: () => { - invalidateTableSchema(queryClient, tableId) + invalidateTableSchemaOnly(queryClient, tableId) }, }) } @@ -1211,7 +1226,7 @@ export function useUpdateColumn({ workspaceId, tableId }: RowMutationContext) { toast.error(error.message, { duration: 5000 }) }, onSettled: () => { - invalidateTableSchema(queryClient, tableId) + invalidateTableSchemaOnly(queryClient, tableId) }, }) } From 136f38ab6fc2c41b6a7e6d7abb2c66de25406998 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Jul 2026 17:58:32 -0700 Subject: [PATCH 17/20] fix(tables): migrate legacy multiselect columns; readable contract error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tables created before the select/multiselect merge stored `type: 'multiselect'`, which the removed type no longer accepts — every response carrying such a column failed contract validation and column edits threw. getTableById now maps `multiselect` → `select` + `multiple: true` on read (covers reads, column ops via withLockedTable, and their responses); the migrated shape persists on the table's next schema write. - requestJson's "Response failed contract validation" now appends a short "field: reason" summary of the Zod issues instead of a bare message, so the failing field is visible. --- apps/sim/lib/api/client/request.ts | 25 ++++++++++++++++++++++++- apps/sim/lib/table/service.ts | 22 ++++++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/api/client/request.ts b/apps/sim/lib/api/client/request.ts index 85a7d2ac7fb..a68eb96fd13 100644 --- a/apps/sim/lib/api/client/request.ts +++ b/apps/sim/lib/api/client/request.ts @@ -161,6 +161,26 @@ function isSchemaValidationError(error: unknown): boolean { ) } +/** + * Compresses a ZodError's issues into a short, readable "field: reason" summary + * so a failed response tells you which field was wrong instead of a bare + * "Response failed contract validation". + */ +function summarizeSchemaIssues(error: unknown): string { + const issues = (error as { issues?: unknown }).issues + if (!Array.isArray(issues)) return '' + const summary = issues + .slice(0, 3) + .map((issue) => { + const path = Array.isArray(issue?.path) ? issue.path.join('.') : '' + const message = typeof issue?.message === 'string' ? issue.message : 'invalid' + return path ? `${path}: ${message}` : message + }) + .join('; ') + const extra = issues.length > 3 ? ` (+${issues.length - 3} more)` : '' + return summary ? `${summary}${extra}` : '' +} + export async function requestJson( contract: C, input: ApiClientRequest @@ -199,9 +219,12 @@ export async function requestJson( return contract.response.schema.parse(parsed) as ContractJsonResponse } catch (error) { if (isSchemaValidationError(error)) { + const details = summarizeSchemaIssues(error) throw new ApiClientError({ status: response.status, - message: 'Response failed contract validation', + message: details + ? `Response failed contract validation — ${details}` + : 'Response failed contract validation', body: parsed, rawBody: raw, }) diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 90f3c6f6e7a..758db8d601e 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -86,6 +86,24 @@ export async function withLockedTable( * the wire. The client doesn't have to join the two arrays itself — every * consumer (grid, sidebar, copilot, mothership) gets the same ordered list. */ +/** + * Backward-compat for the removed `'multiselect'` column type: map it to a + * `select` column with `multiple: true`. Applied on every schema read so legacy + * tables — created before the two types were unified — load, edit, and validate + * under the current model; the migrated shape persists on the table's next + * schema write. + */ +function migrateLegacyColumns(schema: TableSchema): TableSchema { + let mutated = false + const columns = schema.columns.map((c) => { + if ((c.type as string) !== 'multiselect') return c + mutated = true + const { type: _legacyType, ...rest } = c + return { ...rest, type: 'select' as const, multiple: true } + }) + return mutated ? { ...schema, columns } : schema +} + function applyColumnOrderToSchema( schema: TableSchema, metadata: TableMetadata | null @@ -151,7 +169,7 @@ export async function getTableById( id: table.id, name: table.name, description: table.description, - schema: applyColumnOrderToSchema(table.schema as TableSchema, metadata), + schema: applyColumnOrderToSchema(migrateLegacyColumns(table.schema as TableSchema), metadata), metadata, rowCount: Math.max(0, table.rowCount - pendingDeleteRemaining), maxRows: table.maxRows, @@ -215,7 +233,7 @@ export async function listTables( id: t.id, name: t.name, description: t.description, - schema: applyColumnOrderToSchema(t.schema as TableSchema, metadata), + schema: applyColumnOrderToSchema(migrateLegacyColumns(t.schema as TableSchema), metadata), metadata, rowCount: Math.max(0, t.rowCount - pendingDeleteRemaining), maxRows: t.maxRows, From b23728841e5f4e92c618416bd3a330fc7295755e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Jul 2026 18:13:50 -0700 Subject: [PATCH 18/20] revert(tables): drop legacy multiselect read-migration Only local dev tables ever held the removed `multiselect` type (the feature never shipped), so the read-time backward-compat mapping isn't warranted. Keeps the readable contract-validation error from the same change. --- apps/sim/lib/table/service.ts | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 758db8d601e..90f3c6f6e7a 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -86,24 +86,6 @@ export async function withLockedTable( * the wire. The client doesn't have to join the two arrays itself — every * consumer (grid, sidebar, copilot, mothership) gets the same ordered list. */ -/** - * Backward-compat for the removed `'multiselect'` column type: map it to a - * `select` column with `multiple: true`. Applied on every schema read so legacy - * tables — created before the two types were unified — load, edit, and validate - * under the current model; the migrated shape persists on the table's next - * schema write. - */ -function migrateLegacyColumns(schema: TableSchema): TableSchema { - let mutated = false - const columns = schema.columns.map((c) => { - if ((c.type as string) !== 'multiselect') return c - mutated = true - const { type: _legacyType, ...rest } = c - return { ...rest, type: 'select' as const, multiple: true } - }) - return mutated ? { ...schema, columns } : schema -} - function applyColumnOrderToSchema( schema: TableSchema, metadata: TableMetadata | null @@ -169,7 +151,7 @@ export async function getTableById( id: table.id, name: table.name, description: table.description, - schema: applyColumnOrderToSchema(migrateLegacyColumns(table.schema as TableSchema), metadata), + schema: applyColumnOrderToSchema(table.schema as TableSchema, metadata), metadata, rowCount: Math.max(0, table.rowCount - pendingDeleteRemaining), maxRows: table.maxRows, @@ -233,7 +215,7 @@ export async function listTables( id: t.id, name: t.name, description: t.description, - schema: applyColumnOrderToSchema(migrateLegacyColumns(t.schema as TableSchema), metadata), + schema: applyColumnOrderToSchema(t.schema as TableSchema, metadata), metadata, rowCount: Math.max(0, t.rowCount - pendingDeleteRemaining), maxRows: t.maxRows, From 0c465c83d4afbc5503fb331899aeb18f478d9d62 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Jul 2026 18:35:24 -0700 Subject: [PATCH 19/20] improvement(tables): add select options by typing into a trailing row Replace the "Add option" button with a trailing empty row: the first keystroke materializes the option and focus jumps into it (cursor at end) so typing flows straight through. Enter in an option jumps back to the trailing row to add the next one. Removing a row is unchanged. --- .../select-field/select-options-editor.tsx | 72 +++++++++++++++---- 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx index c562be01aa8..3a0366aa222 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx @@ -1,7 +1,8 @@ 'use client' +import { useEffect, useRef, useState } from 'react' import { Button, ChipInput } from '@sim/emcn' -import { Plus, X } from '@sim/emcn/icons' +import { X } from '@sim/emcn/icons' import { generateShortId } from '@sim/utils/id' import type { SelectOption } from '@/lib/table' @@ -11,22 +12,46 @@ interface SelectOptionsEditorProps { } /** - * Add/remove/rename the options of a `select`/`multiselect` column. Option ids - * are stable across edits so existing cell data survives renames. Options - * default to the neutral `gray` pill — per-option colors are not yet exposed - * (the `color` field stays in the model so a picker can be re-added later). + * 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. Options + * default to the neutral `gray` pill (per-option colors aren't exposed yet; the + * `color` field stays in the model so a picker can be re-added later). */ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorProps) { + const inputRefs = useRef>(new Map()) + const trailingRef = useRef(null) + const [pendingFocusId, setPendingFocusId] = useState(null) + + // Focus a freshly materialized option once it has rendered, cursor at end. + // The new row and `pendingFocusId` land in the same commit, so its 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)) } - const add = () => { - onChange([...options, { id: generateShortId(), name: '', color: 'gray' }]) + /** 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, color: 'gray' }]) + setPendingFocusId(id) } return ( @@ -34,8 +59,19 @@ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorPr {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' @@ -52,14 +88,20 @@ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorPr
))} - +
+ { + if (e.target.value) materialize(e.target.value) + }} + placeholder='Add option' + spellCheck={false} + autoComplete='off' + className='min-w-0 flex-1' + /> + +
) } From 228ba69c41ec5b2999437100b2698cbaad85f85b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Jul 2026 18:54:32 -0700 Subject: [PATCH 20/20] fix(tables): export select columns as option names, not ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSV/JSON export serialized the raw stored value for select cells — an option id (or an array of ids for multi) — so downloads showed opaque `opt_…` ids. Export now resolves ids to option names: CSV comma-joins multi names; JSON resolves values before the id→name key translation. Ids with no matching option (deleted) are dropped. --- apps/sim/lib/table/export-format.test.ts | 65 ++++++++++++++++++++++++ apps/sim/lib/table/export-format.ts | 37 ++++++++++++++ apps/sim/lib/table/export-runner.ts | 18 +++++-- 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 apps/sim/lib/table/export-format.test.ts diff --git a/apps/sim/lib/table/export-format.test.ts b/apps/sim/lib/table/export-format.test.ts new file mode 100644 index 00000000000..754085012c0 --- /dev/null +++ b/apps/sim/lib/table/export-format.test.ts @@ -0,0 +1,65 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { formatCsvCell, resolveSelectExportValue } from '@/lib/table/export-format' +import type { ColumnDefinition } from '@/lib/table/types' + +const singleSelect: ColumnDefinition = { + id: 'col_status', + name: 'status', + type: 'select', + options: [ + { id: 'opt_open', name: 'Open', color: 'green' }, + { id: 'opt_closed', name: 'Closed', color: 'red' }, + ], +} + +const multiSelect: ColumnDefinition = { + id: 'col_tags', + name: 'tags', + type: 'select', + multiple: true, + options: [ + { id: 'opt_a', name: 'Alpha', color: 'blue' }, + { id: 'opt_b', name: 'Beta', color: 'purple' }, + ], +} + +describe('resolveSelectExportValue', () => { + it('maps a single option id to its name', () => { + expect(resolveSelectExportValue(singleSelect, 'opt_open')).toBe('Open') + }) + + it('maps multi option ids to a names array in order', () => { + expect(resolveSelectExportValue(multiSelect, ['opt_b', 'opt_a'])).toEqual(['Beta', 'Alpha']) + }) + + it('drops ids with no matching option', () => { + expect(resolveSelectExportValue(multiSelect, ['opt_a', 'gone'])).toEqual(['Alpha']) + }) + + it('returns null for an empty single value', () => { + expect(resolveSelectExportValue(singleSelect, null)).toBeNull() + }) +}) + +describe('formatCsvCell', () => { + it('renders the option name, not the id', () => { + expect(formatCsvCell(singleSelect, 'opt_closed')).toBe('Closed') + }) + + it('comma-joins multi option names', () => { + expect(formatCsvCell(multiSelect, ['opt_a', 'opt_b'])).toBe('Alpha, Beta') + }) + + it('is empty when nothing is selected', () => { + expect(formatCsvCell(singleSelect, null)).toBe('') + expect(formatCsvCell(multiSelect, [])).toBe('') + }) + + it('falls through to the plain formatter for non-select columns', () => { + const num: ColumnDefinition = { id: 'c', name: 'n', type: 'number' } + expect(formatCsvCell(num, 42)).toBe('42') + }) +}) diff --git a/apps/sim/lib/table/export-format.ts b/apps/sim/lib/table/export-format.ts index 3d5256b13fc..ce59646f5f5 100644 --- a/apps/sim/lib/table/export-format.ts +++ b/apps/sim/lib/table/export-format.ts @@ -4,6 +4,30 @@ * byte-identical files. */ +import type { ColumnDefinition } from '@/lib/table/types' + +/** + * Maps a `select` cell's stored option id(s) to their display names — exports + * should read the human-readable label, never the internal id. Single columns + * return the option name (or null); multi columns return an array of names. + * Ids with no matching option (deleted) are dropped. + */ +export function resolveSelectExportValue( + column: ColumnDefinition, + value: unknown +): string | string[] | null { + const byId = new Map((column.options ?? []).map((o) => [o.id, o.name])) + const ids = Array.isArray(value) + ? value + : typeof value === 'string' && value !== '' + ? [value] + : [] + const names = ids + .map((id) => (typeof id === 'string' ? byId.get(id) : undefined)) + .filter((n): n is string => n != null) + return column.multiple ? names : (names[0] ?? null) +} + export function sanitizeExportFilename(name: string): string { const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') return cleaned || 'table' @@ -29,6 +53,19 @@ export function formatCsvValue(value: unknown): string { return String(value) } +/** + * Serializes one cell for CSV, resolving `select` option ids to their names + * (comma-joined for multi) so the file shows the enum label, not the id. + */ +export function formatCsvCell(column: ColumnDefinition, value: unknown): string { + if (column.type === 'select') { + const resolved = resolveSelectExportValue(column, value) + const text = Array.isArray(resolved) ? resolved.join(', ') : (resolved ?? '') + return neutralizeCsvFormula(text) + } + return formatCsvValue(value) +} + export function toCsvRow(values: string[]): string { return values.map(escapeCsvField).join(',') } diff --git a/apps/sim/lib/table/export-runner.ts b/apps/sim/lib/table/export-runner.ts index 6c72a01ff20..192530f968b 100644 --- a/apps/sim/lib/table/export-runner.ts +++ b/apps/sim/lib/table/export-runner.ts @@ -4,8 +4,9 @@ import { generateId } from '@sim/utils/id' import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys' import { appendTableEvent } from '@/lib/table/events' import { - formatCsvValue, + formatCsvCell, neutralizeCsvFormula, + resolveSelectExportValue, sanitizeExportFilename, toCsvRow, } from '@/lib/table/export-format' @@ -59,6 +60,8 @@ export async function runTableExport(payload: TableExportPayload): Promise if (!table) throw new Error(`Export target table ${tableId} not found`) const columns = table.schema.columns + // Select cells store option ids; exports resolve them to option names below. + const selectColumns = columns.filter((c) => c.type === 'select') // 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) @@ -91,12 +94,21 @@ export async function runTableExport(payload: TableExportPayload): Promise for (const row of page) { if (format === 'csv') { pageChunks.push( - `${toCsvRow(columns.map((c) => formatCsvValue(row.data[getColumnId(c)])))}\n` + `${toCsvRow(columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)])))}\n` ) } else { const prefix = firstJsonRow ? '' : ',' firstJsonRow = false - pageChunks.push(prefix + JSON.stringify(rowDataIdToName(row.data, nameById))) + // Resolve select ids → names before the id → name key translation. + let data = row.data + if (selectColumns.length > 0) { + data = { ...data } + for (const c of selectColumns) { + const key = getColumnId(c) + if (key in data) data[key] = resolveSelectExportValue(c, data[key]) + } + } + pageChunks.push(prefix + JSON.stringify(rowDataIdToName(data, nameById))) } } await handle.write(pageChunks.join(''))