From 55bf63867cbe17132977b63f1e13cbadfa43b87b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 22 Jul 2026 14:31:42 -0700 Subject: [PATCH 1/7] 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 2/7] 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' + /> +
))}