From 37640c6239ba7f15e9eb1c592562c2e2080b6f96 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:33:09 -0700 Subject: [PATCH 01/10] feat(tables): add reference column type contract --- apps/sim/lib/api/contracts/tables.test.ts | 44 +++++++++++++- apps/sim/lib/api/contracts/tables.ts | 34 +++++++++-- .../api/contracts/v2/__tests__/tables.test.ts | 31 ++++++++++ apps/sim/lib/api/contracts/v2/tables.ts | 7 +++ .../__tests__/column-type-registry.test.ts | 59 +++++++++++++++---- apps/sim/lib/table/column-types/reference.ts | 48 +++++++++++++++ .../lib/table/column-types/registry.server.ts | 1 + apps/sim/lib/table/column-types/registry.ts | 7 ++- apps/sim/lib/table/column-types/types.ts | 8 ++- apps/sim/lib/table/import.test.ts | 5 ++ apps/sim/lib/table/import.ts | 2 + apps/sim/lib/table/types.ts | 7 +++ apps/sim/lib/table/validation.ts | 1 + 13 files changed, 232 insertions(+), 22 deletions(-) create mode 100644 apps/sim/lib/table/column-types/reference.ts diff --git a/apps/sim/lib/api/contracts/tables.test.ts b/apps/sim/lib/api/contracts/tables.test.ts index 7bdba27d81d..eb5f3669b4c 100644 --- a/apps/sim/lib/api/contracts/tables.test.ts +++ b/apps/sim/lib/api/contracts/tables.test.ts @@ -2,7 +2,49 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { tableEventStreamQuerySchema, tableRowsQuerySchema } from '@/lib/api/contracts/tables' +import { + createTableColumnBodySchema, + tableColumnSchema, + tableEventStreamQuerySchema, + tableRowsQuerySchema, + updateTableColumnBodySchema, +} from '@/lib/api/contracts/tables' + +describe('reference column metadata', () => { + const referenceColumn = { + name: 'account', + type: 'reference', + referenceTableId: 'tbl_accounts', + } + + it('preserves the target table id in every HTTP column schema', () => { + expect(tableColumnSchema.parse(referenceColumn).referenceTableId).toBe('tbl_accounts') + expect( + createTableColumnBodySchema.parse({ + workspaceId: 'ws-1', + column: referenceColumn, + }).column.referenceTableId + ).toBe('tbl_accounts') + expect( + updateTableColumnBodySchema.parse({ + workspaceId: 'ws-1', + columnName: 'account', + updates: { referenceTableId: 'tbl_other' }, + }).updates.referenceTableId + ).toBe('tbl_other') + }) + + it('requires a non-empty target for reference columns', () => { + expect(tableColumnSchema.safeParse({ name: 'account', type: 'reference' }).success).toBe(false) + expect(tableColumnSchema.safeParse({ ...referenceColumn, referenceTableId: '' }).success).toBe( + false + ) + }) + + it('rejects reference metadata on another column type', () => { + expect(tableColumnSchema.safeParse({ ...referenceColumn, type: 'string' }).success).toBe(false) + }) +}) /** * `requestJson` parses the query through this schema on the CLIENT before building the URL, so diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 0a85964f137..f03caff3e22 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -83,11 +83,13 @@ export const currencyCodeSchema = z .regex(/^[A-Za-z]{3}$/, 'Must be a 3-letter ISO 4217 currency code, e.g. USD') .overwrite((code) => code.toUpperCase()) +export const referenceTableIdSchema = requiredFieldSchema('Reference table ID is required') + /** - * Cross-field rule: a `select` column must declare a non-empty option set; - * other types must not carry options or `multiple`, and only a `currency` - * column may carry `currencyCode`. Skipped when `type` is absent (a - * metadata-only update on an existing column). + * Cross-field rules for type-owned metadata. A `select` column must declare a + * non-empty option set, a `reference` column must declare its target table, + * and type-specific fields are rejected on every type that does not own them. + * Skipped when `type` is absent (a metadata-only update on an existing column). */ export function refineColumnOptions( data: { @@ -95,6 +97,7 @@ export function refineColumnOptions( options?: z.infer multiple?: boolean currencyCode?: string + referenceTableId?: string }, ctx: z.RefinementCtx ): void { @@ -108,6 +111,20 @@ export function refineColumnOptions( message: 'currencyCode is only allowed on currency columns', }) } + if (data.type !== undefined && data.type !== 'reference' && data.referenceTableId !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['referenceTableId'], + message: 'referenceTableId is only allowed on reference columns', + }) + } + if (data.type === 'reference' && data.referenceTableId === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['referenceTableId'], + message: 'A reference column must define a reference table ID', + }) + } if (data.type === 'select') { if (!data.options || data.options.length === 0) { ctx.addIssue({ @@ -220,6 +237,9 @@ export const tableColumnSchema = z currencyCode: currencyCodeSchema .optional() .describe('ISO 4217 code for a currency column, normalized to uppercase.'), + referenceTableId: referenceTableIdSchema + .optional() + .describe('Target table whose row IDs are stored by a reference column.'), }) .superRefine(refineColumnOptions) .describe('A typed column in a table schema.') @@ -302,6 +322,9 @@ export const createTableColumnBodySchema = z.object({ options: selectOptionsSchema.optional().describe('Options for a select column.'), multiple: z.boolean().optional().describe('Whether a select column accepts multiple values.'), currencyCode: currencyCodeSchema.optional().describe('ISO 4217 code for a currency column.'), + referenceTableId: referenceTableIdSchema + .optional() + .describe('Target table for a reference column.'), }) .superRefine(refineColumnOptions) .describe('Typed column definition to add.'), @@ -319,6 +342,9 @@ export const updateTableColumnBodySchema = z.object({ options: selectOptionsSchema.optional().describe('Replacement select options.'), multiple: z.boolean().optional().describe('New multi-select setting.'), currencyCode: currencyCodeSchema.optional().describe('New ISO 4217 currency code.'), + referenceTableId: referenceTableIdSchema + .optional() + .describe('New target table for a reference column.'), }) .superRefine(refineColumnOptions) .describe('Column fields to update.'), diff --git a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts index fa7ea3725d1..2314527a884 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts @@ -45,6 +45,37 @@ import { CSV_DURABLE_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' describe('v2 table column contracts', () => { + it('preserves reference table metadata on every public column write', () => { + expect( + v2CreateTableBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + name: 'contacts', + schema: { + columns: [{ name: 'account', type: 'reference', referenceTableId: 'tbl_accounts' }], + }, + }) + ).toMatchObject({ + success: true, + data: { schema: { columns: [{ referenceTableId: 'tbl_accounts' }] } }, + }) + expect( + v2CreateTableColumnBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + column: { name: 'account', type: 'reference', referenceTableId: 'tbl_accounts' }, + }) + ).toMatchObject({ + success: true, + data: { column: { referenceTableId: 'tbl_accounts' } }, + }) + expect( + v2UpdateTableColumnBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + columnName: 'account', + updates: { referenceTableId: 'tbl_other' }, + }) + ).toMatchObject({ success: true, data: { updates: { referenceTableId: 'tbl_other' } } }) + }) + it('accepts required on every public column write so a column round-trips', () => { expect( v2CreateTableBodySchema.safeParse({ diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 4bd634b9d36..82977721d63 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -17,6 +17,7 @@ import { insertTableRowBodyBaseSchema, predicateInputSchema, predicateSchema, + referenceTableIdSchema, refineCancelTableRunsScope, refineColumnOptions, rowAnchorMutexRefine, @@ -472,6 +473,9 @@ const v2TableColumnInputShape = { options: selectOptionsSchema.optional().describe('Select options for select-type columns.'), multiple: z.boolean().optional().describe('Whether a select column accepts multiple values.'), currencyCode: currencyCodeSchema.optional().describe('ISO 4217 code for currency columns.'), + referenceTableId: referenceTableIdSchema + .optional() + .describe('Target table for reference columns.'), } /** @@ -770,6 +774,9 @@ export const v2UpdateTableColumnBodySchema = z currencyCode: currencyCodeSchema .optional() .describe('Replacement ISO 4217 code for a currency column.'), + referenceTableId: referenceTableIdSchema + .optional() + .describe('Replacement target table for a reference column.'), }) .strict() .superRefine(refineColumnOptions) diff --git a/apps/sim/lib/table/__tests__/column-type-registry.test.ts b/apps/sim/lib/table/__tests__/column-type-registry.test.ts index 91ec369e31e..dac58ee8948 100644 --- a/apps/sim/lib/table/__tests__/column-type-registry.test.ts +++ b/apps/sim/lib/table/__tests__/column-type-registry.test.ts @@ -9,6 +9,7 @@ * be spread across those arms, so a new type either satisfies them or fails * here. */ +import { Table as TableIcon } from '@sim/emcn/icons' import { describe, expect, it } from 'vitest' import type { ColumnType } from '@/lib/table/column-types' import { @@ -42,6 +43,17 @@ describe('registry shape', () => { expect(isColumnType('currency')).toBe(true) }) + it('registers reference columns as configured string-backed columns', () => { + const definition = COLUMN_TYPE_REGISTRY.reference + + expect(definition.label).toBe('Reference') + expect(definition.icon).toBe(TableIcon) + expect(definition.requiresConfigurationOnCreate).toBe(true) + expect(definition.hasConfiguration).toBe(true) + expect(definition.ownedMetadata).toEqual(['referenceTableId']) + expect(definition.jsonbCast).toBeNull() + }) + it('only casts to numeric/timestamptz for types whose storage is actually that', () => { // A wrong cast makes every filter and sort on the column fail in SQL. for (const definition of ALL_COLUMN_TYPES) { @@ -179,19 +191,23 @@ describe('metadata ownership', () => { const options = [{ id: 'opt_a', name: 'A' }] it.each` - label | definition | valid | needle - ${'options on select'} | ${column({ type: 'select', options })} | ${true} | ${''} - ${'options on string'} | ${column({ type: 'string', options })} | ${false} | ${'cannot define options'} - ${'options on currency'} | ${column({ type: 'currency', options })} | ${false} | ${'cannot define options'} - ${'multiple on number'} | ${column({ type: 'number', multiple: true })} | ${false} | ${'cannot be multiple'} - ${'code on currency'} | ${column({ type: 'currency', currencyCode: 'USD' })} | ${true} | ${''} - ${'code on number'} | ${column({ type: 'number', currencyCode: 'USD' })} | ${false} | ${'cannot define a currency'} - ${'code on select'} | ${column({ type: 'select', currencyCode: 'USD', options })} | ${false} | ${'cannot define a currency'} - ${'unsupported code'} | ${column({ type: 'currency', currencyCode: 'ZZZ' })} | ${false} | ${'invalid currency code'} - ${'unique on select'} | ${column({ type: 'select', unique: true, options })} | ${false} | ${'cannot be unique'} - ${'unique on currency'} | ${column({ type: 'currency', unique: true })} | ${true} | ${''} - ${'select with no option'} | ${column({ type: 'select' })} | ${false} | ${'at least one option'} - ${'unknown type'} | ${column({ type: 'percent' as ColumnDefinition['type'] })} | ${false} | ${'invalid type'} + label | definition | valid | needle + ${'options on select'} | ${column({ type: 'select', options })} | ${true} | ${''} + ${'options on string'} | ${column({ type: 'string', options })} | ${false} | ${'cannot define options'} + ${'options on currency'} | ${column({ type: 'currency', options })} | ${false} | ${'cannot define options'} + ${'multiple on number'} | ${column({ type: 'number', multiple: true })} | ${false} | ${'cannot be multiple'} + ${'code on currency'} | ${column({ type: 'currency', currencyCode: 'USD' })} | ${true} | ${''} + ${'code on number'} | ${column({ type: 'number', currencyCode: 'USD' })} | ${false} | ${'cannot define a currency'} + ${'code on select'} | ${column({ type: 'select', currencyCode: 'USD', options })} | ${false} | ${'cannot define a currency'} + ${'unsupported code'} | ${column({ type: 'currency', currencyCode: 'ZZZ' })} | ${false} | ${'invalid currency code'} + ${'target on reference'} | ${column({ type: 'reference', referenceTableId: 'tbl_anything' })} | ${true} | ${''} + ${'missing target'} | ${column({ type: 'reference' })} | ${false} | ${'reference table'} + ${'empty target'} | ${column({ type: 'reference', referenceTableId: '' })} | ${false} | ${'reference table'} + ${'target on string'} | ${column({ type: 'string', referenceTableId: 'tbl_other' })} | ${false} | ${'reference another table'} + ${'unique on select'} | ${column({ type: 'select', unique: true, options })} | ${false} | ${'cannot be unique'} + ${'unique on currency'} | ${column({ type: 'currency', unique: true })} | ${true} | ${''} + ${'select with no option'} | ${column({ type: 'select' })} | ${false} | ${'at least one option'} + ${'unknown type'} | ${column({ type: 'percent' as ColumnDefinition['type'] })} | ${false} | ${'invalid type'} `( 'rejects $label', ({ @@ -208,6 +224,23 @@ describe('metadata ownership', () => { if (!valid) expect(result.errors.join(' ').toLowerCase()).toContain(needle.toLowerCase()) } ) + + it('accepts arbitrary row-id strings without resolving them', () => { + const column = { + name: 'account', + type: 'reference', + referenceTableId: 'tbl_accounts', + } as ColumnDefinition + const definition = COLUMN_TYPE_REGISTRY.reference + + expect(definition.coerce('not-a-real-row-id', column)).toEqual({ + ok: true, + value: 'not-a-real-row-id', + }) + expect(definition.coerce(97, column)).toEqual({ ok: true, value: '97' }) + expect(definition.coerce(true, column)).toEqual({ ok: true, value: 'true' }) + expect(definition.validateCell('not-a-real-row-id', column)).toBeNull() + }) }) /** diff --git a/apps/sim/lib/table/column-types/reference.ts b/apps/sim/lib/table/column-types/reference.ts new file mode 100644 index 00000000000..f092c375d5a --- /dev/null +++ b/apps/sim/lib/table/column-types/reference.ts @@ -0,0 +1,48 @@ +import { Table as TableIcon } from '@sim/emcn/icons' +import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' + +export const referenceColumnType: ColumnTypeDefinition = { + id: 'reference', + label: 'Reference', + icon: TableIcon, + jsonbCast: null, + storesOpaqueIds: false, + supportsUnique: true, + requiresConfigurationOnCreate: true, + hasConfiguration: true, + sampleValue: 'row_123', + ownedMetadata: ['referenceTableId'], + workflowInputType: 'string', + editor: 'text', + expandable: false, + + coerce(value) { + if (typeof value === 'string') return { ok: true, value } + if (typeof value === 'number' || typeof value === 'boolean') { + return { ok: true, value: String(value) } + } + return { ok: false } + }, + + validateCell(value, column) { + return typeof value === 'string' ? null : `${column.name} must be a row ID string` + }, + + validateDefinition(column) { + if (typeof column.referenceTableId !== 'string' || column.referenceTableId.length === 0) { + return [`Column "${column.name}" must define a reference table ID`] + } + return [] + }, + + formatForDisplay(value) { + if (typeof value === 'string') return value + if (value === null || value === undefined) return '' + return typeof value === 'object' ? JSON.stringify(value) : String(value) + }, + + formatForInput(value) { + if (typeof value === 'object') return JSON.stringify(value) + return String(value) + }, +} diff --git a/apps/sim/lib/table/column-types/registry.server.ts b/apps/sim/lib/table/column-types/registry.server.ts index 6ba27f5c616..2587b37e648 100644 --- a/apps/sim/lib/table/column-types/registry.server.ts +++ b/apps/sim/lib/table/column-types/registry.server.ts @@ -289,6 +289,7 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record = { date: dateColumnType, json: jsonColumnType, select: selectColumnType, + reference: referenceColumnType, currency: currencyColumnType, } @@ -99,9 +101,8 @@ export function validateTypeMetadata(column: ColumnDefinition): string[] { * A column's type-specific metadata, as a spreadable object. * * Callers that copy a column — the API response serializer, the undo snapshot — - * used to name `options`/`multiple`/`currencyCode` by hand, so a new type's - * metadata was stored but silently dropped on the way out. Reading the key list - * keeps them zero-edit. + * used to name type-specific keys by hand, so a new type's metadata was stored + * but silently dropped on the way out. Reading the key list keeps them zero-edit. */ export function typeMetadataOf(column: ColumnDefinition): Partial { const metadata: Partial = {} diff --git a/apps/sim/lib/table/column-types/types.ts b/apps/sim/lib/table/column-types/types.ts index f481cea0a28..e23348169d8 100644 --- a/apps/sim/lib/table/column-types/types.ts +++ b/apps/sim/lib/table/column-types/types.ts @@ -38,6 +38,7 @@ export const COLUMN_TYPES = [ 'date', 'json', 'select', + 'reference', ] as const export type ColumnType = (typeof COLUMN_TYPES)[number] @@ -60,7 +61,12 @@ export type ColumnCellEditor = * means extending this list and that type's `ownedMetadata` — not editing the * validator. */ -export const TYPE_SPECIFIC_COLUMN_KEYS = ['options', 'multiple', 'currencyCode'] as const +export const TYPE_SPECIFIC_COLUMN_KEYS = [ + 'options', + 'multiple', + 'currencyCode', + 'referenceTableId', +] as const export type TypeSpecificColumnKey = (typeof TYPE_SPECIFIC_COLUMN_KEYS)[number] diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts index 46904728f69..479af24fec5 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -161,6 +161,11 @@ describe('import', () => { expect(coerceValue('yes', 'boolean')).toBeNull() }) + it('keeps imported reference values as row-id strings', () => { + expect(coerceValue('row_external_123', 'reference')).toBe('row_external_123') + expect(coerceValue(97, 'reference')).toBe('97') + }) + it('keeps date-only values as calendar dates, preserves datetime wall times with their offset, and falls back to the original string', () => { expect(coerceValue('2024-01-01', 'date')).toBe('2024-01-01') expect(coerceValue('2024-01-01T12:30:00-07:00', 'date')).toBe('2024-01-01T12:30:00-07:00') diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index e3707b74a99..d47799adf27 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -423,6 +423,8 @@ export function coerceValue( return String(value) } } + case 'reference': + return String(value) default: return String(value) } diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 9319e794c10..e894b526e97 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -74,6 +74,11 @@ export interface ColumnDefinition { * single row. Absent means {@link DEFAULT_CURRENCY_CODE}. */ currencyCode?: string + /** + * Target table for a `reference` column. Cells store row ID strings from this + * table; the IDs are intentionally not checked for existence on write. + */ + referenceTableId?: string } /** The column `type` discriminator, named so callers don't index into the interface. */ @@ -855,6 +860,8 @@ export interface UpdateColumnTypeData { multiple?: boolean /** Currency to set when changing to the `currency` type. */ currencyCode?: string + /** Target table to set when changing to the `reference` type. */ + referenceTableId?: string /** * The `unique` value the same request is about to set. Validated inside the * retype against the post-conversion values, because the conversion is what diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index aa9a918beb8..62cc6b48b8f 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -50,6 +50,7 @@ const FOREIGN_METADATA_VERB: Record = { options: 'define options', multiple: 'be multiple', currencyCode: 'define a currency', + referenceTableId: 'reference another table', } type ValidationSuccess = { valid: true } From b801e22550e320c313c8f7d258ee871c0b3c87e2 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:40:24 -0700 Subject: [PATCH 02/10] feat(tables): persist reference column targets --- .../tools/server/table/user-table.test.ts | 96 ++++++++++- .../copilot/tools/server/table/user-table.ts | 10 +- apps/sim/lib/table/application/columns.ts | 2 + .../table/columns/reference-metadata.test.ts | 162 ++++++++++++++++++ apps/sim/lib/table/columns/service.ts | 90 +++++++++- .../lib/table/orchestration/columns.test.ts | 60 ++++++- apps/sim/lib/table/orchestration/columns.ts | 25 +++ apps/sim/lib/table/types.ts | 15 ++ 8 files changed, 455 insertions(+), 5 deletions(-) create mode 100644 apps/sim/lib/table/columns/reference-metadata.test.ts diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 6c24b403c43..49c68ea1943 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -7,8 +7,10 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import type { TableDefinition } from '@/lib/table' const { + mockAddTableColumn, mockUpdateColumnType, mockUpdateColumnOptions, + mockUpdateColumnReference, mockResolveWorkspaceFileReference, mockGetBoundWorkspaceFileSecretProvenance, mockDownloadWorkspaceFile, @@ -37,8 +39,10 @@ const { mockResolveWorkflowContext, fakeEnrichment, } = vi.hoisted(() => ({ + mockAddTableColumn: vi.fn(), mockUpdateColumnType: vi.fn(), mockUpdateColumnOptions: vi.fn(), + mockUpdateColumnReference: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), mockGetBoundWorkspaceFileSecretProvenance: vi.fn(), mockDownloadWorkspaceFile: vi.fn(), @@ -196,11 +200,13 @@ vi.mock('@/lib/table/workflow-groups/service', () => ({ })) vi.mock('@/lib/table/columns/service', () => ({ - addTableColumn: vi.fn(), + addTableColumn: mockAddTableColumn, deleteColumn: vi.fn(), deleteColumns: mockDeleteColumns, renameColumn: vi.fn(), updateColumnConstraints: vi.fn(), + updateColumnCurrency: vi.fn(), + updateColumnReference: mockUpdateColumnReference, updateColumnType: mockUpdateColumnType, updateColumnOptions: mockUpdateColumnOptions, })) @@ -1582,6 +1588,94 @@ describe('userTableServerTool.update_rows_by_filter', () => { }) }) +describe('userTableServerTool reference column metadata', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTableById.mockResolvedValue(buildTable()) + mockAddTableColumn.mockImplementation( + async (_tableId: string, column: TableDefinition['schema']['columns'][number]) => + buildTable({ schema: { columns: [column] } }) + ) + }) + + it('forwards the target when adding a reference column', async () => { + const result = await userTableServerTool.execute( + { + operation: 'add_column', + args: { + tableId: 'tbl_1', + column: { + name: 'account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }, + }, + }, + buildToolContext() + ) + + expect(result.success).toBe(true) + expect(mockAddTableColumn).toHaveBeenCalledWith( + 'tbl_1', + expect.objectContaining({ + type: 'reference', + referenceTableId: 'tbl_accounts', + }), + expect.any(String), + { expectedWorkspaceId: 'workspace-1' } + ) + }) + + it('forwards a target-only update to the shared reference service', async () => { + const referenceTable = buildTable({ + schema: { + columns: [ + { + id: 'col_account', + name: 'account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }, + ], + }, + }) + mockGetTableById.mockResolvedValue(referenceTable) + mockUpdateColumnReference.mockResolvedValue({ + ...referenceTable, + schema: { + columns: [ + { + ...referenceTable.schema.columns[0], + referenceTableId: 'tbl_companies', + }, + ], + }, + }) + + const result = await userTableServerTool.execute( + { + operation: 'update_column', + args: { + tableId: 'tbl_1', + columnName: 'account', + referenceTableId: 'tbl_companies', + }, + }, + buildToolContext() + ) + + expect(result.success).toBe(true) + expect(mockUpdateColumnReference).toHaveBeenCalledWith( + expect.objectContaining({ + columnName: 'col_account', + referenceTableId: 'tbl_companies', + }), + expect.any(String), + { expectedWorkspaceId: 'workspace-1' } + ) + }) +}) + describe('userTableServerTool.update_column — select routing', () => { const selectTable = buildTable({ schema: { diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index bf79f36b560..3b7a39ec091 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -933,6 +933,7 @@ export const userTableServerTool: BaseServerTool options?: unknown multiple?: boolean currencyCode?: string + referenceTableId?: string } | undefined if (!col?.name || !col?.type) { @@ -1056,17 +1057,21 @@ export const userTableServerTool: BaseServerTool const rawOptions = (args as Record).options const multiple = (args as Record).multiple as boolean | undefined const currencyCode = (args as Record).currencyCode as string | undefined + const referenceTableId = (args as Record).referenceTableId as + | string + | undefined if ( newType === undefined && uniqFlag === undefined && rawOptions === undefined && multiple === undefined && - currencyCode === undefined + currencyCode === undefined && + referenceTableId === undefined ) { return { success: false, message: - 'At least one of newType, unique, options, multiple, or currencyCode must be provided', + 'At least one of newType, unique, options, multiple, currencyCode, or referenceTableId must be provided', } } if (currencyCode !== undefined && !isSupportedCurrencyCode(currencyCode)) { @@ -1097,6 +1102,7 @@ export const userTableServerTool: BaseServerTool ...(rawOptions !== undefined ? { options: rawOptions } : {}), ...(multiple !== undefined ? { multiple } : {}), ...(currencyCode !== undefined ? { currencyCode } : {}), + ...(referenceTableId !== undefined ? { referenceTableId } : {}), }, }, { tableId: args.tableId } diff --git a/apps/sim/lib/table/application/columns.ts b/apps/sim/lib/table/application/columns.ts index 4a56b2c1b4a..9b026c09a74 100644 --- a/apps/sim/lib/table/application/columns.ts +++ b/apps/sim/lib/table/application/columns.ts @@ -36,6 +36,7 @@ export interface AddTableColumnInput extends TableColumnInput { options?: SelectOption[] multiple?: boolean currencyCode?: string + referenceTableId?: string } } @@ -77,6 +78,7 @@ export interface UpdateTableColumnInput extends TableColumnInput { options?: unknown multiple?: boolean currencyCode?: string + referenceTableId?: string } } diff --git a/apps/sim/lib/table/columns/reference-metadata.test.ts b/apps/sim/lib/table/columns/reference-metadata.test.ts new file mode 100644 index 00000000000..8ed6d2781da --- /dev/null +++ b/apps/sim/lib/table/columns/reference-metadata.test.ts @@ -0,0 +1,162 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const mocks = vi.hoisted(() => ({ + withLockedTable: vi.fn(), + set: vi.fn(), + where: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ withLockedTable: mocks.withLockedTable })) + +import { + addTableColumn, + updateColumnReference, + updateColumnType, +} from '@/lib/table/columns/service' + +const BASE_TABLE = { + id: 'tbl_people', + name: 'People', + workspaceId: 'ws_1', + schema: { + columns: [{ id: 'col_name', name: 'Name', type: 'string' }], + }, + metadata: null, + rowCount: 0, +} as unknown as TableDefinition + +function tableWithReference(referenceTableId = 'tbl_accounts'): TableDefinition { + return { + ...BASE_TABLE, + schema: { + columns: [ + { + id: 'col_account', + name: 'Account', + type: 'reference', + referenceTableId, + }, + ], + }, + } +} + +describe('reference column metadata persistence', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.where.mockResolvedValue(undefined) + mocks.set.mockReturnValue({ where: mocks.where }) + }) + + function useTable(table: TableDefinition) { + const trx = { + execute: vi.fn().mockResolvedValue([]), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn(() => ({ limit: vi.fn().mockResolvedValue([]) })), + })), + })), + })), + update: vi.fn(() => ({ set: mocks.set })), + } + mocks.withLockedTable.mockImplementationOnce( + async (_tableId, mutate: (locked: TableDefinition, tx: typeof trx) => Promise) => + mutate(table, trx) + ) + return trx + } + + it('retains referenceTableId when adding a reference column', async () => { + useTable(BASE_TABLE) + + const updated = await addTableColumn( + 'tbl_people', + { name: 'Account', type: 'reference', referenceTableId: 'tbl_accounts' }, + 'req_1' + ) + + expect(updated.schema.columns.at(-1)).toMatchObject({ + name: 'Account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }) + }) + + it('retains the supplied target when converting a column to reference', async () => { + useTable(BASE_TABLE) + + const updated = await updateColumnType( + { + tableId: 'tbl_people', + columnName: 'col_name', + newType: 'reference', + referenceTableId: 'tbl_accounts', + }, + 'req_1' + ) + + expect(updated.schema.columns[0]).toMatchObject({ + id: 'col_name', + type: 'reference', + referenceTableId: 'tbl_accounts', + }) + }) + + it('changes a reference target without reading or rewriting rows', async () => { + const trx = useTable(tableWithReference()) + + const updated = await updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_account', + referenceTableId: 'tbl_companies', + }, + 'req_1' + ) + + expect(updated.schema.columns[0]).toMatchObject({ referenceTableId: 'tbl_companies' }) + expect(trx.select).not.toHaveBeenCalled() + expect(trx.execute).not.toHaveBeenCalled() + expect(trx.update).toHaveBeenCalledOnce() + }) + + it('rejects reference metadata on a non-reference column', async () => { + const trx = useTable(BASE_TABLE) + + await expect( + updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_name', + referenceTableId: 'tbl_accounts', + }, + 'req_1' + ) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(trx.update).not.toHaveBeenCalled() + }) + + it('returns the locked table unchanged when the target is already set', async () => { + const table = tableWithReference() + const trx = useTable(table) + + const updated = await updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_account', + referenceTableId: 'tbl_accounts', + }, + 'req_1' + ) + + expect(updated).toBe(table) + expect(trx.update).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index b68ce8393ac..45dfa9e0eaa 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -55,6 +55,7 @@ import type { UpdateColumnConstraintsData, UpdateColumnCurrencyData, UpdateColumnOptionsData, + UpdateColumnReferenceData, UpdateColumnTypeData, } from '@/lib/table/types' import { validateColumnDefinition } from '@/lib/table/validation' @@ -126,6 +127,7 @@ export async function addTableColumn( options?: SelectOption[] multiple?: boolean currencyCode?: string + referenceTableId?: string }, requestId: string, options?: ColumnMutationOptions @@ -177,6 +179,9 @@ export async function addTableColumn( unique: column.unique ?? false, ...(column.options ? { options: column.options } : {}), ...(column.multiple ? { multiple: true } : {}), + ...(column.referenceTableId !== undefined + ? { referenceTableId: column.referenceTableId } + : {}), ...columnTypeById(column.type).defaultMetadata?.(column as ColumnDefinition), } @@ -891,7 +896,8 @@ export async function updateColumnType( data.unique !== undefined || data.options !== undefined || data.multiple !== undefined || - data.currencyCode !== undefined + data.currencyCode !== undefined || + data.referenceTableId !== undefined if (carriesOtherWork) { throw new OrchestrationError( 'validation', @@ -1455,6 +1461,88 @@ export async function updateColumnCurrency( ) } +/** + * Changes the table targeted by a `reference` column. + * + * Cells already store plain row-ID strings, so changing the target updates only + * the column schema. The target is deliberately not loaded or validated here; + * dangling table and row IDs are valid reference values for now. + */ +export async function updateColumnReference( + data: UpdateColumnReferenceData, + requestId: string, + options?: ColumnMutationOptions +): Promise { + return withLockedTable( + data.tableId, + async (table, trx) => { + assertSchemaMutable(table) + + const schema = table.schema + const columnIndex = schema.columns.findIndex((column) => + columnMatchesRef(column, data.columnName) + ) + if (columnIndex === -1) { + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) + } + + const column = schema.columns[columnIndex] + if (column.type !== 'reference') { + throw new OrchestrationError( + 'validation', + `Cannot set a reference table on column "${column.name}" of type "${column.type}"` + ) + } + + const updatedColumn: ColumnDefinition = { + ...column, + referenceTableId: data.referenceTableId, + } + const columnValidation = validateColumnDefinition(updatedColumn) + if (!columnValidation.valid) { + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) + } + + const constrained = await applyConstraints( + trx, + data.tableId, + table.workspaceId, + updatedColumn, + getColumnId(column), + data + ) + const renamePending = data.newName !== undefined && data.newName !== column.name + if ( + constrained === updatedColumn && + updatedColumn.referenceTableId === column.referenceTableId && + !renamePending + ) { + return table + } + + const withReference = schema.columns.map((existing, index) => + index === columnIndex ? constrained : existing + ) + const updatedColumns = withReference.map((existing, index) => + index === columnIndex + ? applyPendingRename(withReference, columnIndex, data.newName) + : existing + ) + const updated = await persistColumns(trx, table, updatedColumns) + + logger.info( + `[${requestId}] Set reference table for column "${column.name}" to "${data.referenceTableId}" in table ${data.tableId}` + ) + + return updated + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) +} + /** * Rows whose cell counts as empty for a `required` constraint: the key is * missing, the value is JSON null, or it is an emptied multiselect `[]`. diff --git a/apps/sim/lib/table/orchestration/columns.test.ts b/apps/sim/lib/table/orchestration/columns.test.ts index d99eff54342..f1dc938148e 100644 --- a/apps/sim/lib/table/orchestration/columns.test.ts +++ b/apps/sim/lib/table/orchestration/columns.test.ts @@ -13,6 +13,7 @@ const { mockUpdateColumnOptions, mockUpdateColumnConstraints, mockUpdateColumnCurrency, + mockUpdateColumnReference, mockRecordAudit, } = vi.hoisted(() => ({ mockRenameColumn: vi.fn(), @@ -20,6 +21,7 @@ const { mockUpdateColumnOptions: vi.fn(), mockUpdateColumnConstraints: vi.fn(), mockUpdateColumnCurrency: vi.fn(), + mockUpdateColumnReference: vi.fn(), mockRecordAudit: vi.fn(), })) @@ -33,6 +35,7 @@ vi.mock('@/lib/table/columns/service', () => ({ renameColumn: mockRenameColumn, updateColumnConstraints: mockUpdateColumnConstraints, updateColumnCurrency: mockUpdateColumnCurrency, + updateColumnReference: mockUpdateColumnReference, updateColumnOptions: mockUpdateColumnOptions, updateColumnType: mockUpdateColumnType, })) @@ -48,12 +51,18 @@ const SELECT_COLUMN = { options: [{ id: 'opt_open', name: 'Open' }], } const TEXT_COLUMN = { id: 'col-2', name: 'Priority', type: 'text' as const } +const REFERENCE_COLUMN = { + id: 'col-3', + name: 'Account', + type: 'reference' as const, + referenceTableId: 'tbl_accounts', +} const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1', - schema: { columns: [SELECT_COLUMN, TEXT_COLUMN] }, + schema: { columns: [SELECT_COLUMN, TEXT_COLUMN, REFERENCE_COLUMN] }, } as unknown as TableDefinition const UPDATED = { schema: { columns: [SELECT_COLUMN] } } as unknown as TableDefinition @@ -76,6 +85,7 @@ describe('performUpdateTableColumn', () => { mockUpdateColumnOptions.mockResolvedValue(UPDATED) mockUpdateColumnConstraints.mockResolvedValue(UPDATED) mockUpdateColumnCurrency.mockResolvedValue(UPDATED) + mockUpdateColumnReference.mockResolvedValue(UPDATED) }) it('refuses to make a select column unique before writing anything', async () => { @@ -161,6 +171,54 @@ describe('performUpdateTableColumn', () => { expect(mockUpdateColumnType).not.toHaveBeenCalled() }) + it('carries the target through a conversion to reference', async () => { + await run({ type: 'reference', referenceTableId: 'tbl_accounts' }, 'Priority') + + expect(mockUpdateColumnReference).not.toHaveBeenCalled() + expect(mockUpdateColumnType).toHaveBeenCalledWith( + expect.objectContaining({ + newType: 'reference', + referenceTableId: 'tbl_accounts', + }), + 'req-1' + ) + }) + + it('routes a target-only reference update through the schema-only service', async () => { + await run({ referenceTableId: 'tbl_companies' }, 'Account') + + expect(mockUpdateColumnType).not.toHaveBeenCalled() + expect(mockUpdateColumnReference).toHaveBeenCalledWith( + expect.objectContaining({ + columnName: 'col-3', + referenceTableId: 'tbl_companies', + }), + 'req-1' + ) + }) + + it('folds reference metadata, constraints, and rename into one schema write', async () => { + await run({ referenceTableId: 'tbl_companies', required: true, name: 'Company' }, 'Account') + + expect(mockRenameColumn).not.toHaveBeenCalled() + expect(mockUpdateColumnConstraints).not.toHaveBeenCalled() + expect(mockUpdateColumnReference).toHaveBeenCalledWith( + expect.objectContaining({ + referenceTableId: 'tbl_companies', + required: true, + newName: 'Company', + }), + 'req-1' + ) + }) + + it('rejects reference metadata when the resulting type is not reference', async () => { + const result = await run({ referenceTableId: 'tbl_accounts' }, 'Priority') + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockUpdateColumnReference).not.toHaveBeenCalled() + }) + it('reports an empty payload as a validation failure', async () => { const result = await run({}) diff --git a/apps/sim/lib/table/orchestration/columns.ts b/apps/sim/lib/table/orchestration/columns.ts index 48f8f5ffd3d..0108a58de45 100644 --- a/apps/sim/lib/table/orchestration/columns.ts +++ b/apps/sim/lib/table/orchestration/columns.ts @@ -14,6 +14,7 @@ import { updateColumnConstraints, updateColumnCurrency, updateColumnOptions, + updateColumnReference, updateColumnType, } from '@/lib/table/columns/service' import { isSupportedCurrencyCode } from '@/lib/table/currency' @@ -42,6 +43,7 @@ export interface PerformUpdateTableColumnParams { options?: unknown multiple?: boolean currencyCode?: string + referenceTableId?: string } requestId?: string expectedWorkspaceId?: string @@ -114,6 +116,7 @@ export async function performUpdateTableColumn( const typedWriteRuns = typeChanging || updates.currencyCode !== undefined || + updates.referenceTableId !== undefined || options !== undefined || updates.multiple !== undefined const constraintsWriteRuns = @@ -140,6 +143,12 @@ export async function performUpdateTableColumn( ) } } + if (updates.referenceTableId !== undefined && resultingType !== 'reference') { + return fail( + `Cannot set a reference table on column "${columnName}" of type "${resultingType}"`, + 'validation' + ) + } // The rename runs last, so a name already taken would fail after the typed // write committed. This is the only rename failure a caller can cause; // catching it here leaves just the concurrent-collision race. @@ -177,6 +186,9 @@ export async function performUpdateTableColumn( ...(options !== undefined ? { options } : {}), ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), ...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}), + ...(updates.referenceTableId !== undefined + ? { referenceTableId: updates.referenceTableId } + : {}), // Forwarded so the conversion validates against the constraints this // same request is about to set, not the column's current ones. ...(updates.required !== undefined ? { required: updates.required } : {}), @@ -202,6 +214,19 @@ export async function performUpdateTableColumn( requestId, ...workspaceMutationOptions(params.expectedWorkspaceId) ) + } else if (updates.referenceTableId !== undefined) { + updated = await updateColumnReference( + { + tableId, + columnName: columnRef, + referenceTableId: updates.referenceTableId, + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + ...renameWithTypedWrite, + }, + requestId, + ...workspaceMutationOptions(params.expectedWorkspaceId) + ) } else if (options !== undefined || updates.multiple !== undefined) { updated = await updateColumnOptions( { diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index e894b526e97..0ed82c4cc2f 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -914,6 +914,21 @@ export interface UpdateColumnCurrencyData { currencyCode: string } +/** + * Payload for changing the table targeted by a `reference` column. Cells keep + * storing the same row-ID strings, so this is a schema-only update. + */ +export interface UpdateColumnReferenceData { + tableId: string + columnName: string + /** A rename to apply in the SAME transaction as this write. */ + newName?: string + /** Constraints to apply in the SAME transaction as this write. */ + unique?: boolean + required?: boolean + referenceTableId: string +} + export interface UpdateColumnConstraintsData { tableId: string columnName: string From 35d9c8cc361cdc2733c030ff1d372d648f7c58b3 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:45:27 -0700 Subject: [PATCH 03/10] feat(tables): configure reference columns in sidebar --- .../column-config-sidebar.test.tsx | 233 ++++++++++++++++++ .../column-config-sidebar.tsx | 145 ++++++----- .../components/column-config-sidebar/index.ts | 2 +- .../[workspaceId]/tables/[tableId]/table.tsx | 1 - 4 files changed, 324 insertions(+), 57 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx new file mode 100644 index 00000000000..8f7c1c67b38 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx @@ -0,0 +1,233 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +interface ComboboxOption { + label: string + value: string +} + +interface ComboboxProps { + options: ComboboxOption[] + value?: string + placeholder?: string + searchable?: boolean + searchPlaceholder?: string + onChange?: (value: string) => void +} + +interface SelectOptionsEditorProps { + options: Array<{ id: string; name: string }> + onChange: (options: Array<{ id: string; name: string }>) => void +} + +const { + capturedComboboxes, + capturedSelectEditor, + mockAddColumn, + mockUpdateColumn, + mockUseTablesList, +} = vi.hoisted(() => ({ + capturedComboboxes: { current: [] as ComboboxProps[] }, + capturedSelectEditor: { current: null as SelectOptionsEditorProps | null }, + mockAddColumn: vi.fn(), + mockUpdateColumn: vi.fn(), + mockUseTablesList: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), + ChipCombobox: (props: ComboboxProps) => { + capturedComboboxes.current.push(props) + return
+ }, + ChipInput: (props: React.InputHTMLAttributes) => , + FieldDivider: () =>
, + Label: ({ children, ...props }: React.LabelHTMLAttributes) => ( + + ), + Switch: ({ checked }: { checked?: boolean }) => ( + + ), + cn: (...values: Array) => values.filter(Boolean).join(' '), + toast: { error: vi.fn(), success: vi.fn() }, +})) + +vi.mock('@sim/emcn/icons', () => ({ + PlayOutline: () => , + X: () => , +})) + +vi.mock('@/lib/table/column-types', () => ({ + ALL_COLUMN_TYPES: [ + { id: 'string', label: 'Text', icon: () => null }, + { id: 'select', label: 'Select', icon: () => null }, + { id: 'reference', label: 'Reference', icon: () => null }, + ], + columnTypeOf: (type: string) => ({ supportsUnique: type !== 'select' }), +})) + +vi.mock('@/hooks/queries/tables', () => ({ + useAddTableColumn: () => ({ isPending: false, mutateAsync: mockAddColumn }), + useTablesList: mockUseTablesList, + useUpdateColumn: () => ({ isPending: false, mutateAsync: mockUpdateColumn }), +})) + +vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/components/select-field', () => ({ + SelectOptionsEditor: (props: SelectOptionsEditorProps) => { + capturedSelectEditor.current = props + return
+ }, +})) + +import { ColumnConfigSidebar } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar' + +let container: HTMLDivElement +let root: Root + +function findCombobox(placeholder: string): ComboboxProps | undefined { + return capturedComboboxes.current.find((combobox) => combobox.placeholder === placeholder) +} + +function findButton(label: string): HTMLButtonElement | undefined { + return [...container.querySelectorAll('button')].find( + (button) => button.textContent === label + ) +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + capturedComboboxes.current = [] + capturedSelectEditor.current = null + mockUseTablesList.mockReturnValue({ + data: [ + { id: 'table-current', name: 'Current table' }, + { id: 'table-customers', name: 'Customers' }, + ], + }) + mockAddColumn.mockResolvedValue({ data: { columns: [] } }) + mockUpdateColumn.mockResolvedValue({ data: { columns: [] } }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() +}) + +describe('ColumnConfigSidebar', () => { + it('creates a Reference column with the selected workspace table', async () => { + await act(async () => { + root.render( + + ) + }) + + expect(mockUseTablesList).toHaveBeenCalledWith('workspace-1', 'active', { enabled: true }) + expect(container.querySelector('#column-sidebar-name')?.value).toBe( + 'Related row' + ) + expect(findCombobox('Select table')).toMatchObject({ + options: [ + { label: 'Current table', value: 'table-current' }, + { label: 'Customers', value: 'table-customers' }, + ], + searchable: true, + searchPlaceholder: 'Search tables', + }) + + act(() => findCombobox('Select table')?.onChange?.('table-customers')) + await act(async () => findButton('Save')?.click()) + + expect(mockAddColumn).toHaveBeenCalledWith({ + name: 'Related row', + type: 'reference', + referenceTableId: 'table-customers', + }) + }) + + it('edits Reference configuration without exposing column renaming', async () => { + await act(async () => { + root.render( + + ) + }) + + expect(container).not.toHaveTextContent('Column name') + expect(container.querySelector('#column-sidebar-name')).toBeNull() + + act(() => findCombobox('Select table')?.onChange?.('table-customers')) + await act(async () => findButton('Save')?.click()) + + expect(mockUpdateColumn).toHaveBeenCalledWith({ + columnName: 'col-reference', + updates: { referenceTableId: 'table-customers' }, + }) + }) + + it('keeps Select options in the edit sidebar', async () => { + await act(async () => { + root.render( + + ) + }) + + expect(container).toHaveTextContent('Options') + expect(container).toHaveTextContent('Multiselect') + act(() => + capturedSelectEditor.current?.onChange([ + { id: 'option-ready', name: 'Ready' }, + { id: 'option-done', name: 'Done' }, + ]) + ) + await act(async () => findButton('Save')?.click()) + + expect(mockUpdateColumn).toHaveBeenCalledWith({ + columnName: 'col-status', + updates: { + options: [ + { id: 'option-ready', name: 'Ready' }, + { id: 'option-done', name: 'Done' }, + ], + }, + }) + }) +}) 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 ffcef8ce465..a072fc3347c 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 @@ -15,7 +15,8 @@ import { FieldError, RequiredLabel, } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields' -import { useAddTableColumn, useUpdateColumn } from '@/hooks/queries/tables' +import { useAddTableColumn, useTablesList, useUpdateColumn } from '@/hooks/queries/tables' +import { columnTypeOf } from '@/lib/table/column-types' import { SelectOptionsEditor } from '../select-field' import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types' @@ -54,9 +55,6 @@ interface ColumnConfigSidebarProps { existingColumn: ColumnDefinition | null workspaceId: string tableId: string - /** Notify parent of a rename so it can rewrite local `columnOrder` / - * `columnWidths` keys that reference the old name. */ - onColumnRename?: (oldName: string, newName: string) => void } /** @@ -104,7 +102,6 @@ function ColumnConfigBody({ existingColumn, workspaceId, tableId, - onColumnRename, }: ColumnConfigBodyProps) { const updateColumn = useUpdateColumn({ workspaceId, tableId }) const addColumn = useAddTableColumn({ workspaceId, tableId }) @@ -129,14 +126,24 @@ function ColumnConfigBody({ ? resolveCurrencyCode(existingColumn?.currencyCode) : DEFAULT_CURRENCY_CODE ) + const [referenceTableInput, setReferenceTableInput] = useState(() => + config.mode === 'edit' ? (existingColumn?.referenceTableId ?? '') : '' + ) const [showValidation, setShowValidation] = useState(false) const [nameError, setNameError] = useState(null) const [optionsError, setOptionsError] = useState(null) + const [referenceTableError, setReferenceTableError] = useState(null) const saveDisabled = updateColumn.isPending || addColumn.isPending const trimmedName = nameInput.trim() const wantsOptions = isSelectType(typeInput) const wantsCurrency = typeInput === 'currency' + const wantsReference = typeInput === 'reference' + const supportsUnique = columnTypeOf(typeInput).supportsUnique + const { data: workspaceTables = [] } = useTablesList(workspaceId, 'active', { + enabled: wantsReference, + }) + const tableOptions = workspaceTables.map((table) => ({ value: table.id, label: table.name })) const trimmedOptions = optionsInput.map((o) => ({ ...o, name: o.name.trim() })) /** Client-side option validation mirroring the server rules; returns an error message or null. */ @@ -149,8 +156,13 @@ function ColumnConfigBody({ return null } + function validateReferenceTable(): string | null { + if (!wantsReference || referenceTableInput) return null + return 'Select a table' + } + async function handleSave() { - if (!trimmedName) { + if (config.mode === 'create' && !trimmedName) { setShowValidation(true) return } @@ -160,47 +172,48 @@ function ColumnConfigBody({ setOptionsError(optionsIssue) return } + const referenceTableIssue = validateReferenceTable() + if (referenceTableIssue) { + setReferenceTableError(referenceTableIssue) + return + } try { if (config.mode === 'create') { await addColumn.mutateAsync({ name: trimmedName, type: typeInput, - // Select columns don't expose a unique constraint. - ...(!wantsOptions && uniqueInput ? { unique: true } : {}), + ...(supportsUnique && uniqueInput ? { unique: true } : {}), ...(wantsOptions ? { options: trimmedOptions } : {}), ...(wantsOptions && multipleInput ? { multiple: true } : {}), ...(wantsCurrency ? { currencyCode: currencyInput } : {}), + ...(wantsReference ? { referenceTableId: referenceTableInput } : {}), }) toast.success(`Added "${trimmedName}"`) onClose() return } - // `config.columnName` is the column id; compare against the current display - // name to detect an actual rename. - const renamed = trimmedName !== (existingColumn?.name ?? config.columnName) const typeChanged = !!existingColumn && existingColumn.type !== typeInput const uniqueChanged = - !wantsOptions && !!existingColumn && !!existingColumn.unique !== uniqueInput - // Select columns don't offer a Unique control, so converting a unique - // column to select would strand the constraint with no way to clear it. - const uniqueCleared = wantsOptions && !!existingColumn?.unique + supportsUnique && !!existingColumn && !!existingColumn.unique !== uniqueInput + const uniqueCleared = !supportsUnique && !!existingColumn?.unique const optionsChanged = wantsOptions && !optionsEqual(existingColumn?.options ?? [], trimmedOptions) const multipleChanged = wantsOptions && !!existingColumn?.multiple !== multipleInput const currencyChanged = wantsCurrency && resolveCurrencyCode(existingColumn?.currencyCode) !== currencyInput + const referenceTableChanged = + wantsReference && existingColumn?.referenceTableId !== referenceTableInput const updates: { - name?: string type?: ColumnDefinition['type'] unique?: boolean options?: SelectOption[] multiple?: boolean currencyCode?: string + referenceTableId?: string } = { - ...(renamed ? { name: trimmedName } : {}), ...(typeChanged ? { type: typeInput } : {}), ...(uniqueChanged ? { unique: uniqueInput } : {}), ...(uniqueCleared ? { unique: false } : {}), @@ -209,6 +222,9 @@ function ColumnConfigBody({ ...(wantsCurrency && (typeChanged || currencyChanged) ? { currencyCode: currencyInput } : {}), + ...(wantsReference && (typeChanged || referenceTableChanged) + ? { referenceTableId: referenceTableInput } + : {}), } if (Object.keys(updates).length === 0) { onClose() @@ -216,8 +232,7 @@ function ColumnConfigBody({ } await updateColumn.mutateAsync({ columnName: config.columnName, updates }) - if (renamed) onColumnRename?.(config.columnName, trimmedName) - toast.success(`Saved "${trimmedName}"`) + toast.success(`Saved "${existingColumn?.name ?? config.columnName}"`) onClose() } catch (err) { if (isValidationError(err)) { @@ -250,42 +265,41 @@ function ColumnConfigBody({
-
- Column name - { - setNameInput(e.target.value) - if (nameError) setNameError(null) - }} - spellCheck={false} - autoComplete='off' - error={Boolean((showValidation && !trimmedName) || nameError)} - aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined} - /> - {showValidation && !trimmedName && } - {nameError && !(showValidation && !trimmedName) && } -
+ {config.mode === 'create' && ( +
+ Column name + { + setNameInput(e.target.value) + if (nameError) setNameError(null) + }} + spellCheck={false} + autoComplete='off' + error={Boolean((showValidation && !trimmedName) || nameError)} + aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined} + /> + {showValidation && !trimmedName && } + {nameError && !(showValidation && !trimmedName) && } +
+ )} {config.mode === 'edit' && ( - <> - -
- Type - ({ - label: o.label, - value: o.type, - icon: o.icon, - }))} - value={typeInput} - onChange={(v) => setTypeInput(v as ColumnDefinition['type'])} - placeholder='Select type' - maxHeight={300} - /> -
- +
+ Type + ({ + label: o.label, + value: o.type, + icon: o.icon, + }))} + value={typeInput} + onChange={(v) => setTypeInput(v as ColumnDefinition['type'])} + placeholder='Select type' + maxHeight={300} + /> +
)} {wantsCurrency && ( @@ -332,8 +346,29 @@ function ColumnConfigBody({ )} - {/* Select columns don't expose a unique constraint. */} - {!wantsOptions && ( + {wantsReference && ( + <> + +
+ Table + { + setReferenceTableInput(value) + if (referenceTableError) setReferenceTableError(null) + }} + placeholder='Select table' + searchable + searchPlaceholder='Search tables' + maxHeight={260} + /> + {referenceTableError && } +
+ + )} + + {supportsUnique && ( <>
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts index e458001136d..982a38bc509 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts @@ -1,4 +1,4 @@ -export type { ColumnConfig } from './column-config-sidebar' +export type { ColumnConfig, ColumnConfigurationMetadata } from './column-config-sidebar' export { ColumnConfigSidebar } from './column-config-sidebar' export { COLUMN_TYPE_OPTIONS, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index ee9f6b52e27..d5938d7ecba 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -1652,7 +1652,6 @@ export function Table({ } workspaceId={workspaceId} tableId={tableId} - onColumnRename={onColumnRename} /> Date: Tue, 25 Aug 2026 16:45:48 -0700 Subject: [PATCH 04/10] docs(tables): document reference columns --- apps/docs/content/docs/en/tables/index.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/docs/en/tables/index.mdx b/apps/docs/content/docs/en/tables/index.mdx index be5f75947b5..93944b87b52 100644 --- a/apps/docs/content/docs/en/tables/index.mdx +++ b/apps/docs/content/docs/en/tables/index.mdx @@ -26,8 +26,9 @@ Every column has a type, which decides how its values are stored and validated. | **Date** | A date | `2026-03-16` | | **JSON** | An object or array | `{ "tier": "pro" }` | | **Select** | One of a fixed set of options, or several | `Pro` | +| **Reference** | A row ID from another table in your workspace | `row_123` | -Types are enforced as you enter values, so a Number column only takes numbers. +Types are enforced as you enter values, so a Number column only takes numbers. A Reference column is intentionally different for now: it stores the row ID as plain text without checking that the row exists in the selected table. A Currency column stores a plain number and renders it in the currency you choose for that column, so filters, sorts, and exports all see the amount itself. Changing a column's currency relabels it — it does not convert the amounts. From bab5ffcf79097e1ed4b278fb99d945fed9e271ce Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:13:09 -0700 Subject: [PATCH 05/10] fix(tables): harden reference column metadata --- .../column-config-sidebar.test.tsx | 20 +++++++ .../components/table-grid/table-grid.tsx | 8 +-- apps/sim/hooks/use-table-undo.test.ts | 42 +++++++++++++- apps/sim/hooks/use-table-undo.ts | 6 +- apps/sim/lib/api/contracts/tables.test.ts | 40 +++++++++++++ apps/sim/lib/api/contracts/tables.ts | 14 +++-- apps/sim/lib/api/contracts/v2/tables.ts | 8 +-- apps/sim/lib/table/column-types/reference.ts | 20 +++---- .../table/columns/reference-metadata.test.ts | 56 +++++++++++++++++++ apps/sim/lib/table/columns/service.ts | 3 +- apps/sim/lib/table/constants.ts | 3 + .../lib/table/orchestration/columns.test.ts | 26 +++++++++ apps/sim/lib/table/orchestration/columns.ts | 9 +++ apps/sim/stores/table/store.test.ts | 1 + apps/sim/stores/table/types.ts | 9 +-- 15 files changed, 223 insertions(+), 42 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx index 8f7c1c67b38..512b1ead268 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx @@ -162,6 +162,26 @@ describe('ColumnConfigSidebar', () => { }) }) + it('keeps Reference creation open until a target table is selected', async () => { + await act(async () => { + root.render( + + ) + }) + + await act(async () => findButton('Save')?.click()) + + expect(container).toHaveTextContent('Select a table') + expect(mockAddColumn).not.toHaveBeenCalled() + expect(mockUpdateColumn).not.toHaveBeenCalled() + }) + it('edits Reference configuration without exposing column renaming', async () => { await act(async () => { root.render( 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 379f94465c4..a9a3866fb5c 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 @@ -24,7 +24,7 @@ import type { WorkflowGroup, } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' -import { columnTypeOf } from '@/lib/table/column-types' +import { columnTypeOf, typeMetadataOf } from '@/lib/table/column-types' import { TABLE_LIMITS } from '@/lib/table/constants' import { cellValueFilterConditions } from '@/lib/table/query-builder/cell-filter' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' @@ -4045,11 +4045,7 @@ export function TableGrid({ columnPosition: adjustedPosition >= 0 ? adjustedPosition : cols.length, columnUnique: entry.def?.unique ?? false, columnRequired: entry.def?.required ?? false, - // Without these a deleted select column can't be re-created — it is - // invalid with no options, and the saved cell data is option ids. - ...(entry.def?.options ? { columnOptions: entry.def.options } : {}), - ...(entry.def?.multiple ? { columnMultiple: true } : {}), - ...(entry.def?.currencyCode ? { columnCurrencyCode: entry.def.currencyCode } : {}), + columnTypeMetadata: entry.def ? typeMetadataOf(entry.def) : {}, cellData, previousOrder: orderSnapshot, previousWidth, diff --git a/apps/sim/hooks/use-table-undo.test.ts b/apps/sim/hooks/use-table-undo.test.ts index 0456f76c087..8e73702897e 100644 --- a/apps/sim/hooks/use-table-undo.test.ts +++ b/apps/sim/hooks/use-table-undo.test.ts @@ -195,6 +195,7 @@ describe('useTableUndo – delete-column undo cell restore chunking', () => { columnPosition: 0, columnUnique: false, columnRequired: false, + columnTypeMetadata: {}, cellData: [], previousOrder: null, previousWidth: null, @@ -248,8 +249,10 @@ describe('useTableUndo – restoring a deleted select column', () => { columnPosition: 0, columnUnique: false, columnRequired: false, - columnOptions: [{ id: 'opt_open', name: 'Open' }], - columnMultiple: true, + columnTypeMetadata: { + options: [{ id: 'opt_open', name: 'Open' }], + multiple: true, + }, cellData: [], previousOrder: null, previousWidth: null, @@ -272,3 +275,38 @@ describe('useTableUndo – restoring a deleted select column', () => { expect(payload.id).toBe('col_status') }) }) + +describe('useTableUndo – restoring a deleted reference column', () => { + it('re-creates the column with its target table', async () => { + mockPopUndo.mockReturnValueOnce( + makeEntry({ + type: 'delete-column', + columnName: 'owner', + columnId: 'col_owner', + columnType: 'reference', + columnPosition: 0, + columnUnique: false, + columnRequired: false, + columnTypeMetadata: { referenceTableId: 'tbl_people' }, + cellData: [], + previousOrder: null, + previousWidth: null, + previousPinnedColumns: null, + }) + ) + + const { undo } = TestHook() + ;(undo as () => void)() + await flush() + + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'col_owner', + name: 'owner', + type: 'reference', + referenceTableId: 'tbl_people', + }), + expect.any(Object) + ) + }) +}) diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 205e52b8b53..908b6d5464f 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -386,11 +386,7 @@ export function useTableUndo({ type: action.columnType, required: action.columnRequired, unique: action.columnUnique, - // A select column is rejected without its options, and the - // cell data restored below is keyed by those option ids. - ...(action.columnOptions ? { options: action.columnOptions } : {}), - ...(action.columnMultiple ? { multiple: true } : {}), - ...(action.columnCurrencyCode ? { currencyCode: action.columnCurrencyCode } : {}), + ...action.columnTypeMetadata, position: action.columnPosition, }, { diff --git a/apps/sim/lib/api/contracts/tables.test.ts b/apps/sim/lib/api/contracts/tables.test.ts index eb5f3669b4c..36d13bce2e6 100644 --- a/apps/sim/lib/api/contracts/tables.test.ts +++ b/apps/sim/lib/api/contracts/tables.test.ts @@ -9,6 +9,7 @@ import { tableRowsQuerySchema, updateTableColumnBodySchema, } from '@/lib/api/contracts/tables' +import { MAX_REFERENCE_TABLE_ID_LENGTH } from '@/lib/table/constants' describe('reference column metadata', () => { const referenceColumn = { @@ -44,6 +45,45 @@ describe('reference column metadata', () => { it('rejects reference metadata on another column type', () => { expect(tableColumnSchema.safeParse({ ...referenceColumn, type: 'string' }).success).toBe(false) }) + + it('bounds reference table IDs at the standard identifier length', () => { + const maximumId = 't'.repeat(MAX_REFERENCE_TABLE_ID_LENGTH) + const oversizedId = 't'.repeat(MAX_REFERENCE_TABLE_ID_LENGTH + 1) + + expect( + tableColumnSchema.safeParse({ ...referenceColumn, referenceTableId: maximumId }).success + ).toBe(true) + expect( + createTableColumnBodySchema.safeParse({ + workspaceId: 'ws-1', + column: { ...referenceColumn, referenceTableId: maximumId }, + }).success + ).toBe(true) + expect( + updateTableColumnBodySchema.safeParse({ + workspaceId: 'ws-1', + columnName: 'account', + updates: { referenceTableId: maximumId }, + }).success + ).toBe(true) + + expect( + tableColumnSchema.safeParse({ ...referenceColumn, referenceTableId: oversizedId }).success + ).toBe(false) + expect( + createTableColumnBodySchema.safeParse({ + workspaceId: 'ws-1', + column: { ...referenceColumn, referenceTableId: oversizedId }, + }).success + ).toBe(false) + expect( + updateTableColumnBodySchema.safeParse({ + workspaceId: 'ws-1', + columnName: 'account', + updates: { referenceTableId: oversizedId }, + }).success + ).toBe(false) + }) }) /** diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index f03caff3e22..7b1301f0701 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -31,6 +31,7 @@ import type { import { COLUMN_TYPES, FILTER_OPS, + MAX_REFERENCE_TABLE_ID_LENGTH, MAX_RUN_TARGET_ROW_IDS, MAX_SELECT_OPTIONS, MAX_TABLE_BATCH_ITEMS, @@ -83,7 +84,10 @@ export const currencyCodeSchema = z .regex(/^[A-Za-z]{3}$/, 'Must be a 3-letter ISO 4217 currency code, e.g. USD') .overwrite((code) => code.toUpperCase()) -export const referenceTableIdSchema = requiredFieldSchema('Reference table ID is required') +export const referenceTableIdSchema = requiredFieldSchema('Reference table ID is required').max( + MAX_REFERENCE_TABLE_ID_LENGTH, + `Reference table ID must be ${MAX_REFERENCE_TABLE_ID_LENGTH} characters or less` +) /** * Cross-field rules for type-owned metadata. A `select` column must declare a @@ -91,7 +95,7 @@ export const referenceTableIdSchema = requiredFieldSchema('Reference table ID is * and type-specific fields are rejected on every type that does not own them. * Skipped when `type` is absent (a metadata-only update on an existing column). */ -export function refineColumnOptions( +export function refineColumnTypeMetadata( data: { type?: (typeof COLUMN_TYPES)[number] options?: z.infer @@ -241,7 +245,7 @@ export const tableColumnSchema = z .optional() .describe('Target table whose row IDs are stored by a reference column.'), }) - .superRefine(refineColumnOptions) + .superRefine(refineColumnTypeMetadata) .describe('A typed column in a table schema.') export const createTableBodySchema = z.object({ @@ -326,7 +330,7 @@ export const createTableColumnBodySchema = z.object({ .optional() .describe('Target table for a reference column.'), }) - .superRefine(refineColumnOptions) + .superRefine(refineColumnTypeMetadata) .describe('Typed column definition to add.'), }) @@ -346,7 +350,7 @@ export const updateTableColumnBodySchema = z.object({ .optional() .describe('New target table for a reference column.'), }) - .superRefine(refineColumnOptions) + .superRefine(refineColumnTypeMetadata) .describe('Column fields to update.'), }) diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 82977721d63..a742fa94e27 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -19,7 +19,7 @@ import { predicateSchema, referenceTableIdSchema, refineCancelTableRunsScope, - refineColumnOptions, + refineColumnTypeMetadata, rowAnchorMutexRefine, runColumnBodyBaseSchema, runColumnExcludeMutexRefine, @@ -493,7 +493,7 @@ const v2TableColumnInputShape = { export const v2TableColumnInputSchema = z .object(v2TableColumnInputShape) .strict() - .superRefine(refineColumnOptions) + .superRefine(refineColumnTypeMetadata) /** * Initial columns take the same shape as every other v2 column input. @@ -744,7 +744,7 @@ export const v2CreateTableColumnBodySchema = z .describe('Zero-based insertion position for the column.'), }) .strict() - .superRefine(refineColumnOptions) + .superRefine(refineColumnTypeMetadata) .describe('Column definition to add.'), }) .strict() @@ -779,7 +779,7 @@ export const v2UpdateTableColumnBodySchema = z .describe('Replacement target table for a reference column.'), }) .strict() - .superRefine(refineColumnOptions) + .superRefine(refineColumnTypeMetadata) .describe('Mutable column fields.'), }) .strict() diff --git a/apps/sim/lib/table/column-types/reference.ts b/apps/sim/lib/table/column-types/reference.ts index f092c375d5a..df81fd8cb79 100644 --- a/apps/sim/lib/table/column-types/reference.ts +++ b/apps/sim/lib/table/column-types/reference.ts @@ -1,5 +1,7 @@ import { Table as TableIcon } from '@sim/emcn/icons' +import { stringColumnType } from '@/lib/table/column-types/string' import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' +import { MAX_REFERENCE_TABLE_ID_LENGTH } from '@/lib/table/constants' export const referenceColumnType: ColumnTypeDefinition = { id: 'reference', @@ -16,13 +18,7 @@ export const referenceColumnType: ColumnTypeDefinition = { editor: 'text', expandable: false, - coerce(value) { - if (typeof value === 'string') return { ok: true, value } - if (typeof value === 'number' || typeof value === 'boolean') { - return { ok: true, value: String(value) } - } - return { ok: false } - }, + coerce: stringColumnType.coerce, validateCell(value, column) { return typeof value === 'string' ? null : `${column.name} must be a row ID string` @@ -32,6 +28,11 @@ export const referenceColumnType: ColumnTypeDefinition = { if (typeof column.referenceTableId !== 'string' || column.referenceTableId.length === 0) { return [`Column "${column.name}" must define a reference table ID`] } + if (column.referenceTableId.length > MAX_REFERENCE_TABLE_ID_LENGTH) { + return [ + `Column "${column.name}" reference table ID must be ${MAX_REFERENCE_TABLE_ID_LENGTH} characters or less`, + ] + } return [] }, @@ -41,8 +42,5 @@ export const referenceColumnType: ColumnTypeDefinition = { return typeof value === 'object' ? JSON.stringify(value) : String(value) }, - formatForInput(value) { - if (typeof value === 'object') return JSON.stringify(value) - return String(value) - }, + formatForInput: stringColumnType.formatForInput, } diff --git a/apps/sim/lib/table/columns/reference-metadata.test.ts b/apps/sim/lib/table/columns/reference-metadata.test.ts index 8ed6d2781da..720c325d27e 100644 --- a/apps/sim/lib/table/columns/reference-metadata.test.ts +++ b/apps/sim/lib/table/columns/reference-metadata.test.ts @@ -3,6 +3,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_REFERENCE_TABLE_ID_LENGTH } from '@/lib/table/constants' import type { TableDefinition } from '@/lib/table/types' const mocks = vi.hoisted(() => ({ @@ -159,4 +160,59 @@ describe('reference column metadata persistence', () => { expect(updated).toBe(table) expect(trx.update).not.toHaveBeenCalled() }) + + it('does not rewrite the schema when the target and supplied constraints are unchanged', async () => { + const table = tableWithReference() + table.schema.columns[0] = { ...table.schema.columns[0], required: true, unique: true } + const trx = useTable(table) + + const updated = await updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_account', + referenceTableId: 'tbl_accounts', + required: true, + unique: true, + }, + 'req_1' + ) + + expect(updated).toBe(table) + expect(trx.update).not.toHaveBeenCalled() + }) + + it('accepts a reference table ID at the standard identifier length', async () => { + const maximumId = 't'.repeat(MAX_REFERENCE_TABLE_ID_LENGTH) + useTable(tableWithReference()) + + const updated = await updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_account', + referenceTableId: maximumId, + }, + 'req_1' + ) + + expect(updated.schema.columns[0]).toMatchObject({ referenceTableId: maximumId }) + expect(mocks.set).toHaveBeenCalledOnce() + }) + + it('rejects a reference table ID longer than the standard identifier length', async () => { + const oversizedId = 't'.repeat(MAX_REFERENCE_TABLE_ID_LENGTH + 1) + const trx = useTable(tableWithReference()) + + await expect( + updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_account', + referenceTableId: oversizedId, + }, + 'req_1' + ) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(trx.update).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 45dfa9e0eaa..184cfb7cb02 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -1516,7 +1516,8 @@ export async function updateColumnReference( ) const renamePending = data.newName !== undefined && data.newName !== column.name if ( - constrained === updatedColumn && + constrained.required === column.required && + constrained.unique === column.unique && updatedColumn.referenceTableId === column.referenceTableId && !renamePending ) { diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index 2da9bf6b58a..6438131d054 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -12,6 +12,9 @@ import { env, envNumber } from '@/lib/core/config/env' */ export const MAX_TABLE_BATCH_ITEMS = 100 +/** Maximum length of the table identifier stored by a reference column. */ +export const MAX_REFERENCE_TABLE_ID_LENGTH = 128 + export const DEFAULT_TABLE_VIEW_NAME = 'Default' export const TABLE_LIMITS = { diff --git a/apps/sim/lib/table/orchestration/columns.test.ts b/apps/sim/lib/table/orchestration/columns.test.ts index f1dc938148e..505dc1ad09e 100644 --- a/apps/sim/lib/table/orchestration/columns.test.ts +++ b/apps/sim/lib/table/orchestration/columns.test.ts @@ -77,6 +77,15 @@ function run(updates: Record, columnName = 'Status') { }) } +function expectNoServiceWrite() { + expect(mockRenameColumn).not.toHaveBeenCalled() + expect(mockUpdateColumnType).not.toHaveBeenCalled() + expect(mockUpdateColumnOptions).not.toHaveBeenCalled() + expect(mockUpdateColumnConstraints).not.toHaveBeenCalled() + expect(mockUpdateColumnCurrency).not.toHaveBeenCalled() + expect(mockUpdateColumnReference).not.toHaveBeenCalled() +} + describe('performUpdateTableColumn', () => { beforeEach(() => { vi.clearAllMocks() @@ -219,6 +228,23 @@ describe('performUpdateTableColumn', () => { expect(mockUpdateColumnReference).not.toHaveBeenCalled() }) + it('rejects select options when converting a column to reference', async () => { + const result = await run( + { type: 'reference', referenceTableId: 'tbl_accounts', options: ['Open'] }, + 'Priority' + ) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expectNoServiceWrite() + }) + + it('rejects select multiple metadata when updating a reference column', async () => { + const result = await run({ referenceTableId: 'tbl_companies', multiple: true }, 'Account') + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expectNoServiceWrite() + }) + it('reports an empty payload as a validation failure', async () => { const result = await run({}) diff --git a/apps/sim/lib/table/orchestration/columns.ts b/apps/sim/lib/table/orchestration/columns.ts index 0108a58de45..21bd0bca189 100644 --- a/apps/sim/lib/table/orchestration/columns.ts +++ b/apps/sim/lib/table/orchestration/columns.ts @@ -149,6 +149,15 @@ export async function performUpdateTableColumn( 'validation' ) } + if ( + (updates.options !== undefined || updates.multiple !== undefined) && + resultingType !== 'select' + ) { + return fail( + `Cannot set select metadata on column "${columnName}" of type "${resultingType}"`, + 'validation' + ) + } // The rename runs last, so a name already taken would fail after the typed // write committed. This is the only rename failure a caller can cause; // catching it here leaves just the concurrent-collision race. diff --git a/apps/sim/stores/table/store.test.ts b/apps/sim/stores/table/store.test.ts index e1acbe309ea..b58cfcd4e75 100644 --- a/apps/sim/stores/table/store.test.ts +++ b/apps/sim/stores/table/store.test.ts @@ -19,6 +19,7 @@ const deleteColumn: TableUndoAction = { columnPosition: 0, columnUnique: false, columnRequired: false, + columnTypeMetadata: {}, cellData: [], previousOrder: ['a', 'b'], previousWidth: null, diff --git a/apps/sim/stores/table/types.ts b/apps/sim/stores/table/types.ts index 1da15ace218..84bd5a877e9 100644 --- a/apps/sim/stores/table/types.ts +++ b/apps/sim/stores/table/types.ts @@ -55,14 +55,7 @@ export type TableUndoAction = columnPosition: number columnUnique: boolean columnRequired: boolean - // A `select` column is invalid without its option set, so the snapshot has - // to carry it or the restore is rejected — and the saved cell data, which - // holds option ids, would have nothing to attach to. - columnOptions?: ColumnDefinition['options'] - columnMultiple?: boolean - // Likewise for a `currency` column: without its code the restore would - // silently re-denominate every cell to the default currency. - columnCurrencyCode?: string + columnTypeMetadata: Partial cellData: Array<{ rowId: string; value: unknown }> previousOrder: string[] | null previousWidth: number | null From f9e8162be213596381d858c3b817bd7e2fec91b4 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:07:54 -0700 Subject: [PATCH 06/10] feat(tables): add row ID copy and reference navigation --- .../context-menu/context-menu.test.tsx | 101 +++++++++++++ .../components/context-menu/context-menu.tsx | 9 ++ .../table-grid/headers/column-header-menu.tsx | 4 + .../headers/workflow-group-meta-cell.test.tsx | 137 ++++++++++++++++++ .../headers/workflow-group-meta-cell.tsx | 15 +- .../components/table-grid/table-grid.tsx | 18 ++- 6 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.test.tsx new file mode 100644 index 00000000000..1527b63802d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.test.tsx @@ -0,0 +1,101 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/emcn', () => ({ + DropdownMenu: ({ children, open }: { children: ReactNode; open: boolean }) => + open ? <>{children} : null, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + disabled, + onSelect, + }: { + children: ReactNode + disabled?: boolean + onSelect?: () => void + }) => ( + + ), + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})) + +vi.mock('@sim/emcn/icons', () => ({ + ArrowDown: () => null, + ArrowUp: () => null, + Blimp: () => null, + Duplicate: () => null, + Eye: () => null, + ListFilter: () => null, + Pencil: () => null, + PlayOutline: () => null, + RefreshCw: () => null, + Square: () => null, + Trash: () => null, +})) + +import { ContextMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + act(() => { + root = createRoot(container) + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function findButton(label: string): HTMLButtonElement | undefined { + return Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label + ) +} + +describe('table row ContextMenu', () => { + it('places Copy Row Id directly below Duplicate row and invokes its handler', () => { + const onCopyRowId = vi.fn() + + act(() => { + root.render( + + ) + }) + + const labels = Array.from(container.querySelectorAll('button')).map((button) => + button.textContent?.trim() + ) + expect(labels.indexOf('Copy Row Id')).toBe(labels.indexOf('Duplicate row') + 1) + + act(() => findButton('Copy Row Id')?.click()) + expect(onCopyRowId).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index 3bdc488b998..19b83568859 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -35,6 +35,8 @@ interface ContextMenuProps { onInsertAbove: () => void onInsertBelow: () => void onDuplicate: () => void + /** Copies the stable id of the row that opened the menu. Omit for an empty grid slot. */ + onCopyRowId?: () => void onViewExecution?: () => void canViewExecution?: boolean canEditCell?: boolean @@ -95,6 +97,7 @@ export function ContextMenu({ onInsertAbove, onInsertBelow, onDuplicate, + onCopyRowId, onViewExecution, canViewExecution = false, canEditCell = true, @@ -253,6 +256,12 @@ export function ContextMenu({ Duplicate row + {onCopyRowId && ( + + + Copy Row Id + + )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index 04025f40920..3a524cba847 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -24,6 +24,8 @@ interface ColumnHeaderMenuProps { onColumnSelect: (colIndex: number, shiftKey: boolean) => void onInsertLeft: (columnName: string) => void onInsertRight: (columnName: string) => void + /** Opens the table targeted by a Reference column. */ + onGoToReferenceTable?: (tableId: string) => void onDeleteColumn: (columnName: string) => void onResizeStart: (columnKey: string) => void onResize: (columnKey: string, width: number) => void @@ -74,6 +76,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onColumnSelect, onInsertLeft, onInsertRight, + onGoToReferenceTable, onDeleteColumn, onResizeStart, onResize, @@ -346,6 +349,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ column={column} deleteLabel={deleteLabel} onOpenConfig={onOpenConfig} + onGoToReferenceTable={onGoToReferenceTable} onInsertLeft={onInsertLeft} onInsertRight={onInsertRight} onDeleteColumn={onDeleteColumn} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.test.tsx new file mode 100644 index 00000000000..bc79d37a27b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.test.tsx @@ -0,0 +1,137 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ColumnDefinition } from '@/lib/table' + +vi.mock('@sim/emcn', () => ({ + cn: (...values: Array) => values.filter(Boolean).join(' '), + DropdownMenu: ({ children, open }: { children: ReactNode; open: boolean }) => + open ? <>{children} : null, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => ( + + ), + DropdownMenuSeparator: () =>
, + DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => {children}, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})) + +vi.mock('@sim/emcn/icons', () => ({ + ArrowDown: () => null, + ArrowLeft: () => null, + ArrowRight: () => null, + ArrowUp: () => null, + Eye: () => null, + EyeOff: () => null, + Fingerprint: () => null, + Pencil: () => null, + Pin: () => null, + PinOff: () => null, + PlayOutline: () => null, + Settings: () => null, + SquareArrowUpRight: () => null, + Trash: () => null, + Workflow: () => null, + X: () => null, +})) + +vi.mock('@/lib/table/column-types', () => ({ + columnTypeOf: (column: ColumnDefinition) => ({ + icon: () => null, + label: column.type === 'reference' ? 'Reference' : 'Text', + hasConfiguration: column.type === 'reference', + }), +})) + +vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar', () => ({ + PLAIN_COLUMN_TYPE_OPTIONS: [], +})) + +vi.mock('@/enrichments/registry', () => ({ getEnrichment: () => undefined })) + +import { ColumnOptionsMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + act(() => { + root = createRoot(container) + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function renderMenu(column: ColumnDefinition, onGoToReferenceTable: (tableId: string) => void) { + act(() => { + root.render( + + ) + }) +} + +function findButton(label: string): HTMLButtonElement | undefined { + return Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label + ) +} + +describe('ColumnOptionsMenu Reference navigation', () => { + it('opens the table targeted by a Reference column', () => { + const onGoToReferenceTable = vi.fn() + renderMenu( + { + id: 'col-account', + name: 'Account', + type: 'reference', + referenceTableId: 'table-accounts', + }, + onGoToReferenceTable + ) + + act(() => findButton('Go to Reference Table')?.click()) + + expect(onGoToReferenceTable).toHaveBeenCalledWith('table-accounts') + }) + + it('does not show the action for a non-Reference column', () => { + renderMenu({ id: 'col-name', name: 'Name', type: 'string' }, vi.fn()) + + expect(findButton('Go to Reference Table')).toBeUndefined() + }) + + it('does not show the action when Reference metadata has no target table', () => { + renderMenu({ id: 'col-account', name: 'Account', type: 'reference' }, vi.fn()) + + expect(findButton('Go to Reference Table')).toBeUndefined() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx index e9f4e435e11..3c1c129eb4b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx @@ -24,6 +24,7 @@ import { Pin, PinOff, PlayOutline, + SquareArrowUpRight, Trash, Workflow, X, @@ -66,10 +67,12 @@ interface ColumnOptionsMenuProps { column: DisplayColumn /** Override for the destructive item's label. Defaults to "Delete column" * for both plain columns and workflow groups. Use "Hide column" when the - * destructive action is non-lossy (workflow-output column where removing - * it leaves the group with siblings). */ + * destructive action is non-lossy (workflow-output column where removing + * it leaves the group with siblings). */ deleteLabel?: string onOpenConfig: (columnName: string) => void + /** Opens the table targeted by a Reference column. */ + onGoToReferenceTable?: (tableId: string) => void onInsertLeft: (columnName: string) => void onInsertRight: (columnName: string) => void onDeleteColumn: (columnName: string) => void @@ -122,6 +125,7 @@ export function ColumnOptionsMenu({ column, deleteLabel, onOpenConfig, + onGoToReferenceTable, onInsertLeft, onInsertRight, onDeleteColumn, @@ -142,6 +146,7 @@ export function ColumnOptionsMenu({ const showRunActions = Boolean(onRunColumnAll && onRunColumnIncomplete) const showRunSelected = Boolean(onRunColumnSelected) && selectedRowCount > 0 const runLabels = runMenuLabels(hasActiveFilter) + const referenceTableId = column.type === 'reference' ? column.referenceTableId : undefined return ( @@ -228,6 +233,12 @@ export function ColumnOptionsMenu({ View workflow
)} + {referenceTableId && onGoToReferenceTable && ( + onGoToReferenceTable(referenceTableId)}> + + Go to Reference Table + + )} onOpenConfig(column.key)}> Edit column 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 a9a3866fb5c..4cc098a096b 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 @@ -8,7 +8,7 @@ import { createLogger } from '@sim/logger' import type { TableCellSelection } from '@sim/realtime-protocol/table-presence' import { getErrorMessage } from '@sim/utils/errors' import { useVirtualizer } from '@tanstack/react-virtual' -import { useParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables' import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard' @@ -472,6 +472,7 @@ export function TableGrid({ const params = useParams() const workspaceId = propWorkspaceId || (params.workspaceId as string) const tableId = propTableId || (params.tableId as string) + const router = useRouter() const posthog = usePostHog() useEffect(() => { @@ -1698,6 +1699,19 @@ export function TableGrid({ ) } + function handleCopyRowId() { + const rowId = contextMenu.row?.id + if (!rowId) return + void navigator.clipboard.writeText(rowId).catch(() => {}) + } + + const handleGoToReferenceTable = useCallback( + (referenceTableId: string) => { + router.push(`/workspace/${workspaceId}/tables/${referenceTableId}`) + }, + [router, workspaceId] + ) + const handleAppendRow = useCallback(async () => { if (isAppendingRowRef.current) return isAppendingRowRef.current = true @@ -4814,6 +4828,7 @@ export function TableGrid({ workflowGroups={tableWorkflowGroups} sourceInfo={columnSourceInfo.get(column.key)} onOpenConfig={handleConfigureColumn} + onGoToReferenceTable={handleGoToReferenceTable} onViewWorkflow={handleViewWorkflow} onSortColumn={onSortColumn} onClearSort={onClearSort} @@ -4987,6 +5002,7 @@ export function TableGrid({ onInsertAbove={handleInsertRowAbove} onInsertBelow={handleInsertRowBelow} onDuplicate={handleDuplicateRow} + onCopyRowId={contextMenu.row ? handleCopyRowId : undefined} onViewExecution={handleViewExecution} canViewExecution={ (Boolean(contextMenuExecutionId) && contextMenuHasStartedRun) || From ea62a43cc26f86037f2e564b30531d98ce9cb540 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:23:07 -0700 Subject: [PATCH 07/10] improvement(tables): restore sidebar column configuration --- .../column-config-sidebar.test.tsx | 6 +- .../column-config-sidebar.tsx | 4 +- .../components/column-config-sidebar/index.ts | 2 +- .../table-grid/headers/column-header-menu.tsx | 14 +++- .../headers/workflow-group-meta-cell.test.tsx | 19 +++++- .../headers/workflow-group-meta-cell.tsx | 19 ++++-- .../components/table-grid/table-grid.tsx | 67 +++++++++++++++++-- .../components/table-grid/utils.test.ts | 21 ++++++ .../[tableId]/components/table-grid/utils.ts | 24 ++++++- 9 files changed, 157 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx index 512b1ead268..449b77caefd 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx @@ -48,9 +48,7 @@ vi.mock('@sim/emcn', () => ({ }, ChipInput: (props: React.InputHTMLAttributes) => , FieldDivider: () =>
, - Label: ({ children, ...props }: React.LabelHTMLAttributes) => ( - - ), + Label: ({ children }: { children: React.ReactNode }) => {children}, Switch: ({ checked }: { checked?: boolean }) => (
) : readOnly ? ( @@ -349,6 +360,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ column={column} deleteLabel={deleteLabel} onOpenConfig={onOpenConfig} + onRenameColumn={isWorkflowOutput ? undefined : onRenameColumn} onGoToReferenceTable={onGoToReferenceTable} onInsertLeft={onInsertLeft} onInsertRight={onInsertRight} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.test.tsx index bc79d37a27b..5990675745f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.test.tsx @@ -75,7 +75,11 @@ afterEach(() => { container.remove() }) -function renderMenu(column: ColumnDefinition, onGoToReferenceTable: (tableId: string) => void) { +function renderMenu( + column: ColumnDefinition, + onGoToReferenceTable: (tableId: string) => void, + onRenameColumn?: (columnName: string) => void +) { act(() => { root.render( ) }) @@ -135,3 +141,14 @@ describe('ColumnOptionsMenu Reference navigation', () => { expect(findButton('Go to Reference Table')).toBeUndefined() }) }) + +describe('ColumnOptionsMenu editing', () => { + it('starts inline rename from the column menu', () => { + const onRenameColumn = vi.fn() + renderMenu({ id: 'col-name', name: 'Name', type: 'string' }, vi.fn(), onRenameColumn) + + act(() => findButton('Rename column')?.click()) + + expect(onRenameColumn).toHaveBeenCalledWith('col-name') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx index 3c1c129eb4b..f0d0b279369 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx @@ -67,10 +67,12 @@ interface ColumnOptionsMenuProps { column: DisplayColumn /** Override for the destructive item's label. Defaults to "Delete column" * for both plain columns and workflow groups. Use "Hide column" when the - * destructive action is non-lossy (workflow-output column where removing - * it leaves the group with siblings). */ + * destructive action is non-lossy (workflow-output column where removing + * it leaves the group with siblings). */ deleteLabel?: string onOpenConfig: (columnName: string) => void + /** Starts inline renaming for a plain or enrichment column. */ + onRenameColumn?: (columnName: string) => void /** Opens the table targeted by a Reference column. */ onGoToReferenceTable?: (tableId: string) => void onInsertLeft: (columnName: string) => void @@ -114,9 +116,9 @@ interface ColumnOptionsMenuProps { /** * Shared column-options dropdown rendered next to the column header chevron * AND on right-click of the workflow group meta cell. Anchors to a fixed - * position passed in (so callers can place it under the chevron, or at the - * cursor for context-menu use). Rename / change type / unique live in the - * column sidebar (opened by Edit column). + * position passed in so callers can place it under the chevron or at the + * cursor. Rename starts in the header; type, uniqueness, and type-specific + * configuration live in the sidebar opened by Edit column. */ export function ColumnOptionsMenu({ open, @@ -125,6 +127,7 @@ export function ColumnOptionsMenu({ column, deleteLabel, onOpenConfig, + onRenameColumn, onGoToReferenceTable, onInsertLeft, onInsertRight, @@ -243,6 +246,12 @@ export function ColumnOptionsMenu({ Edit column + {onRenameColumn && ( + onRenameColumn(column.key)}> + + Rename column + + )} {onPinToggle && ( onPinToggle(column.key)}> {isPinned ? : } 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 4cc098a096b..e5b581b0292 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 @@ -10,6 +10,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { useVirtualizer } from '@tanstack/react-virtual' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' +import { extractValidationIssues, isValidationError } from '@/lib/api/client/errors' import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables' import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard' import { captureEvent } from '@/lib/posthog/client' @@ -75,6 +76,7 @@ import { chipRowCount, classifyExecStatusMix, collectRowSnapshots, + columnNameIssue, computeNormalizedSelection, drainTargetForChip, type ExecStatusMix, @@ -1481,6 +1483,8 @@ export function TableGrid({ const handleFindCloseRef = useRef(handleFindClose) handleFindCloseRef.current = handleFindClose + const [renameError, setRenameError] = useState(false) + const columnRename = useInlineRename({ // `columnName` is the column id; record the prior display name + id so undo // restores the label (not the id) and targets the right column. @@ -1488,9 +1492,51 @@ export function TableGrid({ const oldName = columnsRef.current.find((c) => c.key === columnName)?.name ?? columnName pushUndoRef.current({ type: 'rename-column', oldName, newName, columnId: columnName }) handleColumnRename(columnName, newName) - return updateColumnMutation.mutateAsync({ columnName, updates: { name: newName } }) + return updateColumnMutation + .mutateAsync({ columnName, updates: { name: newName } }) + .catch((error: unknown) => { + if (isValidationError(error)) { + toast.error(extractValidationIssues(error)[0]?.message ?? getErrorMessage(error)) + } + setRenameError(true) + throw error + }) }, }) + const columnRenameRef = useRef(columnRename) + columnRenameRef.current = columnRename + + const handleRenameValueChange = useCallback((value: string) => { + setRenameError(false) + columnRenameRef.current.setEditValue(value) + }, []) + + /** Keeps invalid names in the header so the user can correct them in place. */ + const handleRenameSubmit = useCallback(() => { + const { editingId, editValue, submitRename } = columnRenameRef.current + const trimmedName = editValue.trim() + const currentColumn = columnsRef.current.find((column) => column.key === editingId) + if (trimmedName && currentColumn && trimmedName !== currentColumn.name) { + const issue = columnNameIssue( + trimmedName, + schemaColumnsRef.current + .filter((column) => getColumnId(column) !== editingId) + .map((column) => column.name) + ) + if (issue) { + toast.error(issue) + setRenameError(true) + return + } + } + setRenameError(false) + void submitRename() + }, []) + + const handleRenameCancel = useCallback(() => { + setRenameError(false) + columnRenameRef.current.cancelRename() + }, []) const toggleBooleanCell = useCallback( (rowId: string, columnName: string, currentValue: unknown) => { @@ -3922,6 +3968,15 @@ export function TableGrid({ [onOpenColumnConfig, onOpenWorkflowConfig, workflowGroupById] ) + const handleRenameColumn = useCallback( + (columnName: string) => { + setRenameError(false) + const column = columnsRef.current.find((candidate) => candidate.key === columnName) + columnRename.startRename(columnName, column?.name ?? columnName) + }, + [columnRename.startRename] + ) + const handleConfigureWorkflowGroup = useCallback( (groupId: string) => { const group = workflowGroupById.get(groupId) @@ -4801,9 +4856,10 @@ export function TableGrid({ renameValue={ columnRename.editingId === column.key ? columnRename.editValue : '' } - onRenameValueChange={columnRename.setEditValue} - onRenameSubmit={columnRename.submitRename} - onRenameCancel={columnRename.cancelRename} + renameError={renameError && columnRename.editingId === column.key} + onRenameValueChange={handleRenameValueChange} + onRenameSubmit={handleRenameSubmit} + onRenameCancel={handleRenameCancel} onColumnSelect={handleColumnSelect} // Required props here, and the menu is already // suppressed for non-editors by `readOnly`. @@ -4828,6 +4884,9 @@ export function TableGrid({ workflowGroups={tableWorkflowGroups} sourceInfo={columnSourceInfo.get(column.key)} onOpenConfig={handleConfigureColumn} + onRenameColumn={ + userPermissions.canEdit ? handleRenameColumn : undefined + } onGoToReferenceTable={handleGoToReferenceTable} onViewWorkflow={handleViewWorkflow} onSortColumn={onSortColumn} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts index 80534939ca4..77a68db51c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts @@ -12,6 +12,7 @@ import { buildTableSelectionContext, canWriteRowsWithChip, chipRowCount, + columnNameIssue, drainTargetForChip, horizontalEdgeScrollVelocity, selectedColumnIds, @@ -197,3 +198,23 @@ describe('drainTargetForChip', () => { expect(drainTargetForChip(0)).toBe(MAX_TABLE_SELECTION_ROWS) }) }) + +describe('columnNameIssue', () => { + it('accepts a pattern-safe, unused name', () => { + expect(columnNameIssue('email_address', ['name', 'status'])).toBeNull() + }) + + it('refuses invalid patterns and names that begin with a digit', () => { + expect(columnNameIssue('New Text', [])).toMatch(/letter or underscore/) + expect(columnNameIssue('1st', [])).toMatch(/letter or underscore/) + }) + + it('refuses a name longer than the column-name limit', () => { + const longName = 'a'.repeat(TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH + 1) + expect(columnNameIssue(longName, [])).toMatch(/characters or less/) + }) + + it('refuses an existing name case-insensitively', () => { + expect(columnNameIssue('EMAIL', ['email'])).toBe('A column named "email" already exists') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts index 4f3e9282d17..a0bb079943a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts @@ -12,7 +12,7 @@ import type { WorkflowGroup, } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' -import { TABLE_LIMITS } from '@/lib/table/constants' +import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' import { areGroupDepsSatisfied, areOutputsFilled } from '@/lib/table/deps' import type { ChatContext } from '@/stores/panel' import type { DeletedRowSnapshot } from '@/stores/table/types' @@ -486,3 +486,25 @@ export function canWriteRowsWithChip(opts: { if (!opts.hasContext || !opts.complete) return false return opts.rowCount > 0 && opts.rowCount <= TABLE_LIMITS.MAX_COPY_ROWS } + +/** + * Returns a user-facing reason that a proposed column name cannot be saved, + * or `null` when the name is valid and unused. + * + * @param takenNames Names of every other column in the table. + */ +export function columnNameIssue(name: string, takenNames: Iterable): string | null { + if (name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { + return `Column names must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less` + } + if (!NAME_PATTERN.test(name)) { + return 'Column names must start with a letter or underscore and use only letters, numbers, and underscores' + } + const lowerName = name.toLowerCase() + for (const takenName of takenNames) { + if (takenName.toLowerCase() === lowerName) { + return `A column named "${takenName}" already exists` + } + } + return null +} From 640b6dc8492b0c428dec0b4136636541383b3a5c Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:26:21 -0700 Subject: [PATCH 08/10] refactor(tables): reuse clipboard helper --- .../tables/[tableId]/components/table-grid/table-grid.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 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 e5b581b0292..85f8d0cddfc 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 @@ -2,7 +2,7 @@ import type React from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { cn, toast, useToast } from '@sim/emcn' +import { cn, toast, useToast, writeTextToClipboard } from '@sim/emcn' import { Loader, TableX } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import type { TableCellSelection } from '@sim/realtime-protocol/table-presence' @@ -1748,7 +1748,7 @@ export function TableGrid({ function handleCopyRowId() { const rowId = contextMenu.row?.id if (!rowId) return - void navigator.clipboard.writeText(rowId).catch(() => {}) + void writeTextToClipboard(rowId).catch(() => {}) } const handleGoToReferenceTable = useCallback( From 334575df5bd8aad0c07876268454efba36ee8dbf Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:53:54 -0700 Subject: [PATCH 09/10] fix(tables): validate reference targets --- .../headers/workflow-group-meta-cell.test.tsx | 1 - .../__tests__/column-type-registry.test.ts | 2 - apps/sim/lib/table/column-types/reference.ts | 2 - .../column-types/registry.server.test.ts | 103 ++++++++++++++++++ .../lib/table/column-types/registry.server.ts | 53 ++++++++- .../lib/table/column-types/types.server.ts | 12 +- apps/sim/lib/table/column-types/types.ts | 4 +- .../table/columns/reference-metadata.test.ts | 47 ++++++++ apps/sim/lib/table/columns/service.ts | 8 +- apps/sim/lib/table/service.test.ts | 47 ++++++++ apps/sim/lib/table/service.ts | 2 + 11 files changed, 265 insertions(+), 16 deletions(-) create mode 100644 apps/sim/lib/table/column-types/registry.server.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.test.tsx index 5990675745f..856befb552a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.test.tsx @@ -46,7 +46,6 @@ vi.mock('@/lib/table/column-types', () => ({ columnTypeOf: (column: ColumnDefinition) => ({ icon: () => null, label: column.type === 'reference' ? 'Reference' : 'Text', - hasConfiguration: column.type === 'reference', }), })) diff --git a/apps/sim/lib/table/__tests__/column-type-registry.test.ts b/apps/sim/lib/table/__tests__/column-type-registry.test.ts index dac58ee8948..90d0f14a1ac 100644 --- a/apps/sim/lib/table/__tests__/column-type-registry.test.ts +++ b/apps/sim/lib/table/__tests__/column-type-registry.test.ts @@ -48,8 +48,6 @@ describe('registry shape', () => { expect(definition.label).toBe('Reference') expect(definition.icon).toBe(TableIcon) - expect(definition.requiresConfigurationOnCreate).toBe(true) - expect(definition.hasConfiguration).toBe(true) expect(definition.ownedMetadata).toEqual(['referenceTableId']) expect(definition.jsonbCast).toBeNull() }) diff --git a/apps/sim/lib/table/column-types/reference.ts b/apps/sim/lib/table/column-types/reference.ts index df81fd8cb79..7138c7fe866 100644 --- a/apps/sim/lib/table/column-types/reference.ts +++ b/apps/sim/lib/table/column-types/reference.ts @@ -10,8 +10,6 @@ export const referenceColumnType: ColumnTypeDefinition = { jsonbCast: null, storesOpaqueIds: false, supportsUnique: true, - requiresConfigurationOnCreate: true, - hasConfiguration: true, sampleValue: 'row_123', ownedMetadata: ['referenceTableId'], workflowInputType: 'string', diff --git a/apps/sim/lib/table/column-types/registry.server.test.ts b/apps/sim/lib/table/column-types/registry.server.test.ts new file mode 100644 index 00000000000..14f64905dd9 --- /dev/null +++ b/apps/sim/lib/table/column-types/registry.server.test.ts @@ -0,0 +1,103 @@ +/** + * @vitest-environment node + */ + +import { hasMockCondition, schemaMock } from '@sim/testing' +import { describe, expect, it, vi } from 'vitest' +import { assertColumnReferencesInWorkspace } from '@/lib/table/column-types/registry.server' +import type { DbTransaction } from '@/lib/table/planner' + +function transactionWithTargets(targetIds: string[]) { + const where = vi.fn().mockResolvedValue(targetIds.map((id) => ({ id }))) + const from = vi.fn(() => ({ where })) + const select = vi.fn(() => ({ from })) + return { + trx: { select } as unknown as DbTransaction, + select, + where, + } +} + +describe('assertColumnReferencesInWorkspace', () => { + it('skips the database when no column type references a table', async () => { + const { trx, select } = transactionWithTargets([]) + + await assertColumnReferencesInWorkspace(trx, 'ws_1', [ + { id: 'col_name', name: 'Name', type: 'string' }, + ]) + + expect(select).not.toHaveBeenCalled() + }) + + it('accepts active Reference targets returned for the workspace', async () => { + const { trx, select, where } = transactionWithTargets(['tbl_accounts', 'tbl_companies']) + + await assertColumnReferencesInWorkspace(trx, 'ws_1', [ + { + id: 'col_account', + name: 'Account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }, + { + id: 'col_company', + name: 'Company', + type: 'reference', + referenceTableId: 'tbl_companies', + }, + { + id: 'col_duplicate', + name: 'Duplicate', + type: 'reference', + referenceTableId: 'tbl_accounts', + }, + ]) + + expect(select).toHaveBeenCalledOnce() + const condition = where.mock.calls[0][0] + expect(hasMockCondition(condition, (node) => node.type === 'eq' && node.right === 'ws_1')).toBe( + true + ) + expect( + hasMockCondition( + condition, + (node) => + node.type === 'inArray' && + node.column === schemaMock.userTableDefinitions.id && + Array.isArray(node.values) && + node.values.length === 2 + ) + ).toBe(true) + expect( + hasMockCondition( + condition, + (node) => + node.type === 'isNull' && node.column === schemaMock.userTableDefinitions.archivedAt + ) + ).toBe(true) + }) + + it('conceals missing, archived, and cross-workspace targets as not found', async () => { + const { trx } = transactionWithTargets(['tbl_accounts']) + + await expect( + assertColumnReferencesInWorkspace(trx, 'ws_1', [ + { + id: 'col_account', + name: 'Account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }, + { + id: 'col_company', + name: 'Company', + type: 'reference', + referenceTableId: 'tbl_unavailable', + }, + ]) + ).rejects.toMatchObject({ + code: 'not_found', + message: 'Reference table "tbl_unavailable" not found in this workspace', + }) + }) +}) diff --git a/apps/sim/lib/table/column-types/registry.server.ts b/apps/sim/lib/table/column-types/registry.server.ts index 2587b37e648..44fbde09b2f 100644 --- a/apps/sim/lib/table/column-types/registry.server.ts +++ b/apps/sim/lib/table/column-types/registry.server.ts @@ -11,8 +11,9 @@ * under any other type. `currency` needs only the inbound one. */ -import { userTableRows } from '@sim/db/schema' -import { and, eq, sql } from 'drizzle-orm' +import { userTableDefinitions, userTableRows } from '@sim/db/schema' +import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { COLUMN_TYPE_REGISTRY } from '@/lib/table/column-types/registry' import type { ColumnType } from '@/lib/table/column-types/types' import type { @@ -21,7 +22,7 @@ import type { } from '@/lib/table/column-types/types.server' import type { DbTransaction } from '@/lib/table/planner' import { updateTableRowsWithDerivedSecretProvenance } from '@/lib/table/rows/secret-provenance' -import type { JsonValue, SelectOption } from '@/lib/table/types' +import type { ColumnDefinition, JsonValue, SelectOption } from '@/lib/table/types' /** * Rewrites a column's cells from stored option **ids** to option **names**, for @@ -289,7 +290,51 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record + typeof column.referenceTableId === 'string' ? [column.referenceTableId] : [], + }, +} + +/** + * Validates every table ID referenced by column metadata in one query. + * + * This intentionally validates only the target table. Cell values remain + * opaque row-ID strings and are never checked for existence. + */ +export async function assertColumnReferencesInWorkspace( + trx: DbTransaction, + workspaceId: string, + columns: readonly ColumnDefinition[] +): Promise { + const referencedTableIds = [ + ...new Set( + columns.flatMap( + (column) => COLUMN_TYPE_SERVER_REGISTRY[column.type].referencedTableIds?.(column) ?? [] + ) + ), + ] + if (referencedTableIds.length === 0) return + + const targets = await trx + .select({ id: userTableDefinitions.id }) + .from(userTableDefinitions) + .where( + and( + eq(userTableDefinitions.workspaceId, workspaceId), + inArray(userTableDefinitions.id, referencedTableIds), + isNull(userTableDefinitions.archivedAt) + ) + ) + const foundIds = new Set(targets.map((target) => target.id)) + const missingId = referencedTableIds.find((id) => !foundIds.has(id)) + if (missingId) { + throw new OrchestrationError( + 'not_found', + `Reference table "${missingId}" not found in this workspace` + ) + } } /** The inbound migration for a target type, if it has one. */ diff --git a/apps/sim/lib/table/column-types/types.server.ts b/apps/sim/lib/table/column-types/types.server.ts index 48f34f812e9..b569c73a0ea 100644 --- a/apps/sim/lib/table/column-types/types.server.ts +++ b/apps/sim/lib/table/column-types/types.server.ts @@ -1,9 +1,9 @@ /** - * The server-only half of a column type: rewriting stored cells when a column - * is converted into or out of this type. + * The server-only half of a column type: database-backed definition checks and + * stored-cell rewrites for conversion into or out of the type. * * Separate from `types.ts` so the client-safe definition never references a - * drizzle transaction type. Mirrors `connectors/`'s `ConnectorMeta` / + * Drizzle transaction type. Mirrors `connectors/`'s `ConnectorMeta` / * `ConnectorConfig` split. */ @@ -31,6 +31,12 @@ export interface ColumnCellMigrationContext { export type ColumnCellMigration = (context: ColumnCellMigrationContext) => Promise export interface ColumnTypeServerDefinition { + /** + * Table IDs named by this column's type-specific metadata. The server + * registry uses this to validate cross-table references in one batch before + * a schema is persisted. Omitted by types that do not reference tables. + */ + readonly referencedTableIds?: (column: ColumnDefinition) => readonly string[] /** * Rewrites cells into this type's canonical storage shape when a column is * converted **to** it. Omitted when the stored bytes are already correct. diff --git a/apps/sim/lib/table/column-types/types.ts b/apps/sim/lib/table/column-types/types.ts index e23348169d8..31980ec45fc 100644 --- a/apps/sim/lib/table/column-types/types.ts +++ b/apps/sim/lib/table/column-types/types.ts @@ -12,8 +12,8 @@ * `scripts/check-client-boundary-imports.ts` only forbids calling a * `'use client'` export from a server surface). It must NOT reach `@sim/db`, * `drizzle-orm`, or `next/server` — the tables grid imports it directly. - * - `ColumnTypeServerDefinition` (in `types.server.ts`) adds the one genuinely - * server-only concern: rewriting stored cells inside a transaction. + * - `ColumnTypeServerDefinition` (in `types.server.ts`) adds database-backed + * definition checks and stored-cell rewrites inside a transaction. * * This mirrors `connectors/types.ts`'s `ConnectorMeta` / `ConnectorConfig` * split and its `registry.ts` / `registry.server.ts` pair. diff --git a/apps/sim/lib/table/columns/reference-metadata.test.ts b/apps/sim/lib/table/columns/reference-metadata.test.ts index 720c325d27e..2d66d297e33 100644 --- a/apps/sim/lib/table/columns/reference-metadata.test.ts +++ b/apps/sim/lib/table/columns/reference-metadata.test.ts @@ -8,11 +8,21 @@ import type { TableDefinition } from '@/lib/table/types' const mocks = vi.hoisted(() => ({ withLockedTable: vi.fn(), + assertColumnReferencesInWorkspace: vi.fn(), + migrationFrom: vi.fn(), + migrationTo: vi.fn(), + writeBackCoercedCells: vi.fn(), set: vi.fn(), where: vi.fn(), })) vi.mock('@/lib/table/service', () => ({ withLockedTable: mocks.withLockedTable })) +vi.mock('@/lib/table/column-types/registry.server', () => ({ + assertColumnReferencesInWorkspace: mocks.assertColumnReferencesInWorkspace, + migrationFrom: mocks.migrationFrom, + migrationTo: mocks.migrationTo, + writeBackCoercedCells: mocks.writeBackCoercedCells, +})) import { addTableColumn, @@ -50,6 +60,10 @@ function tableWithReference(referenceTableId = 'tbl_accounts'): TableDefinition describe('reference column metadata persistence', () => { beforeEach(() => { vi.clearAllMocks() + mocks.assertColumnReferencesInWorkspace.mockResolvedValue(undefined) + mocks.migrationFrom.mockReturnValue(undefined) + mocks.migrationTo.mockReturnValue(undefined) + mocks.writeBackCoercedCells.mockResolvedValue(undefined) mocks.where.mockResolvedValue(undefined) mocks.set.mockReturnValue({ where: mocks.where }) }) @@ -87,6 +101,11 @@ describe('reference column metadata persistence', () => { type: 'reference', referenceTableId: 'tbl_accounts', }) + expect(mocks.assertColumnReferencesInWorkspace).toHaveBeenCalledWith( + expect.anything(), + 'ws_1', + [expect.objectContaining({ referenceTableId: 'tbl_accounts' })] + ) }) it('retains the supplied target when converting a column to reference', async () => { @@ -107,6 +126,11 @@ describe('reference column metadata persistence', () => { type: 'reference', referenceTableId: 'tbl_accounts', }) + expect(mocks.assertColumnReferencesInWorkspace).toHaveBeenCalledWith( + expect.anything(), + 'ws_1', + [expect.objectContaining({ referenceTableId: 'tbl_accounts' })] + ) }) it('changes a reference target without reading or rewriting rows', async () => { @@ -122,6 +146,11 @@ describe('reference column metadata persistence', () => { ) expect(updated.schema.columns[0]).toMatchObject({ referenceTableId: 'tbl_companies' }) + expect(mocks.assertColumnReferencesInWorkspace).toHaveBeenCalledWith( + expect.anything(), + 'ws_1', + [expect.objectContaining({ referenceTableId: 'tbl_companies' })] + ) expect(trx.select).not.toHaveBeenCalled() expect(trx.execute).not.toHaveBeenCalled() expect(trx.update).toHaveBeenCalledOnce() @@ -144,6 +173,24 @@ describe('reference column metadata persistence', () => { expect(trx.update).not.toHaveBeenCalled() }) + it('leaves the source schema unchanged when the target table is unavailable', async () => { + const trx = useTable(tableWithReference()) + mocks.assertColumnReferencesInWorkspace.mockRejectedValueOnce({ code: 'not_found' }) + + await expect( + updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_account', + referenceTableId: 'tbl_missing', + }, + 'req_1' + ) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(trx.update).not.toHaveBeenCalled() + }) + it('returns the locked table unchanged when the target is already set', async () => { const table = tableWithReference() const trx = useTable(table) diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 184cfb7cb02..cfbbfeb8d46 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -29,6 +29,7 @@ import { TYPE_SPECIFIC_COLUMN_KEYS, } from '@/lib/table/column-types' import { + assertColumnReferencesInWorkspace, migrationFrom, migrationTo, writeBackCoercedCells, @@ -192,6 +193,7 @@ export async function addTableColumn( `Invalid column: ${columnValidation.errors.join('; ')}` ) } + await assertColumnReferencesInWorkspace(trx, table.workspaceId, [newColumn]) const newColumnId = getColumnId(newColumn) @@ -950,6 +952,7 @@ export async function updateColumnType( isSelectType, targetMultiple: !!targetMultiple, }) + await assertColumnReferencesInWorkspace(trx, table.workspaceId, [convertedColumn]) let incompatibleCount = 0 let blankCount = 0 @@ -1465,8 +1468,8 @@ export async function updateColumnCurrency( * Changes the table targeted by a `reference` column. * * Cells already store plain row-ID strings, so changing the target updates only - * the column schema. The target is deliberately not loaded or validated here; - * dangling table and row IDs are valid reference values for now. + * the column schema. The target must be an active table in the same workspace; + * stored row IDs remain opaque strings and are not checked for existence. */ export async function updateColumnReference( data: UpdateColumnReferenceData, @@ -1505,6 +1508,7 @@ export async function updateColumnReference( `Invalid column: ${columnValidation.errors.join('; ')}` ) } + await assertColumnReferencesInWorkspace(trx, table.workspaceId, [updatedColumn]) const constrained = await applyConstraints( trx, diff --git a/apps/sim/lib/table/service.test.ts b/apps/sim/lib/table/service.test.ts index 3bde50fa497..0085ce82229 100644 --- a/apps/sim/lib/table/service.test.ts +++ b/apps/sim/lib/table/service.test.ts @@ -12,6 +12,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DbOrTx } from '@/lib/db/types' import type { TableSchema } from '@/lib/table/types' +const mocks = vi.hoisted(() => ({ + assertColumnReferencesInWorkspace: vi.fn(), +})) + +vi.mock('@/lib/table/column-types/registry.server', () => ({ + assertColumnReferencesInWorkspace: mocks.assertColumnReferencesInWorkspace, +})) + vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceTablesChanged: vi.fn().mockResolvedValue(undefined), })) @@ -58,6 +66,7 @@ describe('createTable schema invariants', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + mocks.assertColumnReferencesInWorkspace.mockResolvedValue(undefined) }) /** @@ -113,6 +122,44 @@ describe('createTable schema invariants', () => { }) ) }) + + it('validates Reference targets before persisting the new table', async () => { + queueTableRows(schemaMock.userTableDefinitions, [{ count: 0 }]) + + await create({ + columns: [ + { + name: 'account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }, + ], + } as TableSchema) + + expect(mocks.assertColumnReferencesInWorkspace).toHaveBeenCalledWith( + expect.anything(), + WORKSPACE_ID, + [expect.objectContaining({ referenceTableId: 'tbl_accounts' })] + ) + }) + + it('does not insert a table when a Reference target is unavailable', async () => { + mocks.assertColumnReferencesInWorkspace.mockRejectedValueOnce({ code: 'not_found' }) + + await expect( + create({ + columns: [ + { + name: 'account', + type: 'reference', + referenceTableId: 'tbl_missing', + }, + ], + } as TableSchema) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) }) const TABLE_ID = '0f2b1a4a-1e0e-4b4a-9a0f-0a2b3c4d5e6f' diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 6063ec9be9c..d07969be06d 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -36,6 +36,7 @@ import { resolveRestoredFolderId } from '@/lib/folders/queries' import { notifyWorkspaceTablesChanged } from '@/lib/realtime/notify' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { generateColumnId, getColumnId, withGeneratedColumnIds } from '@/lib/table/column-keys' +import { assertColumnReferencesInWorkspace } from '@/lib/table/column-types/registry.server' import { COLUMN_TYPES, DEFAULT_TABLE_VIEW_NAME, @@ -619,6 +620,7 @@ export async function createTable( await db.transaction(async (trx) => { await setTableTxTimeouts(trx) await trx.execute(sql`SELECT 1 FROM workspace WHERE id = ${data.workspaceId} FOR UPDATE`) + await assertColumnReferencesInWorkspace(trx, data.workspaceId, schema.columns) const [{ count: existingCount }] = await trx .select({ count: count() }) From c7f0c4d8bf066193d6ba056d4cccf71ff2d0ddeb Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:34:16 -0700 Subject: [PATCH 10/10] improvement(tables): rename columns on double click --- .../headers/column-header-menu.test.tsx | 188 ++++++++++++++++++ .../table-grid/headers/column-header-menu.tsx | 12 +- .../headers/workflow-group-meta-cell.test.tsx | 17 +- .../headers/workflow-group-meta-cell.tsx | 9 - 4 files changed, 199 insertions(+), 27 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.test.tsx new file mode 100644 index 00000000000..385ea531154 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.test.tsx @@ -0,0 +1,188 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkflowGroup } from '@/lib/table' +import type { DisplayColumn } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types' + +vi.mock('@sim/emcn', () => ({ + cn: (...values: Array) => values.filter(Boolean).join(' '), +})) + +vi.mock('@sim/emcn/icons', () => ({ + ChevronDown: () => null, +})) + +vi.mock( + '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon', + () => ({ ColumnTypeIcon: () => null }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/header-label', + () => ({ HeaderLabel: ({ label }: { label: string }) => {label} }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell', + () => ({ ColumnOptionsMenu: () => null }) +) + +import { ColumnHeaderMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu' + +let container: HTMLDivElement +let root: Root + +const DEFAULT_COLUMN: DisplayColumn = { + id: 'col-name', + key: 'col-name', + name: 'Name', + type: 'string', + groupSize: 1, + groupStartColIndex: 0, + headerLabel: 'Name', + isGroupStart: true, +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + act(() => { + root = createRoot(container) + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function renderHeader({ + column = DEFAULT_COLUMN, + workflowGroups, + onColumnSelect = vi.fn(), + onOpenConfig = vi.fn(), + onRenameColumn = vi.fn(), +}: { + column?: DisplayColumn + workflowGroups?: WorkflowGroup[] + onColumnSelect?: (colIndex: number, shiftKey: boolean) => void + onOpenConfig?: (columnName: string) => void + onRenameColumn?: (columnName: string) => void +} = {}) { + act(() => { + root.render( + + + + + + +
+ ) + }) + + const headerButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes(column.workflowGroupId ? column.headerLabel : column.name) + ) + if (!headerButton) throw new Error('Column header button was not rendered') + return headerButton +} + +describe('ColumnHeaderMenu interactions', () => { + it('selects the column without opening configuration on a single click', () => { + const onColumnSelect = vi.fn() + const onOpenConfig = vi.fn() + const onRenameColumn = vi.fn() + const headerButton = renderHeader({ onColumnSelect, onOpenConfig, onRenameColumn }) + + act(() => headerButton.click()) + + expect(onColumnSelect).toHaveBeenCalledWith(2, false) + expect(onOpenConfig).not.toHaveBeenCalled() + expect(onRenameColumn).not.toHaveBeenCalled() + }) + + it('selects before starting inline rename on a double click', () => { + const onColumnSelect = vi.fn() + const onRenameColumn = vi.fn() + const headerButton = renderHeader({ onColumnSelect, onRenameColumn }) + + act(() => { + headerButton.click() + headerButton.click() + headerButton.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + }) + + expect(onColumnSelect).toHaveBeenCalledTimes(2) + expect(onRenameColumn).toHaveBeenCalledWith('col-name') + }) + + it('does not rename a workflow-output column on double click', () => { + const onRenameColumn = vi.fn() + const headerButton = renderHeader({ + column: { ...DEFAULT_COLUMN, workflowGroupId: 'workflow-group' }, + workflowGroups: [ + { + id: 'workflow-group', + workflowId: 'workflow-1', + type: 'manual', + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'col-name' }], + }, + ], + onRenameColumn, + }) + + act(() => { + headerButton.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + }) + + expect(onRenameColumn).not.toHaveBeenCalled() + }) + + it('renames an enrichment column on double click', () => { + const onRenameColumn = vi.fn() + const headerButton = renderHeader({ + column: { ...DEFAULT_COLUMN, workflowGroupId: 'enrichment-group' }, + workflowGroups: [ + { + id: 'enrichment-group', + workflowId: '', + enrichmentId: 'company-domain', + type: 'enrichment', + outputs: [{ blockId: '', path: '', outputId: 'domain', columnName: 'col-name' }], + }, + ], + onRenameColumn, + }) + + act(() => { + headerButton.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + }) + + expect(onRenameColumn).toHaveBeenCalledWith('col-name') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index 20971324698..b36fe800164 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -26,7 +26,7 @@ interface ColumnHeaderMenuProps { onColumnSelect: (colIndex: number, shiftKey: boolean) => void onInsertLeft: (columnName: string) => void onInsertRight: (columnName: string) => void - /** Starts inline renaming for a plain or enrichment column. */ + /** Starts inline renaming when a plain or enrichment header is double-clicked. */ onRenameColumn?: (columnName: string) => void /** Opens the table targeted by a Reference column. */ onGoToReferenceTable?: (tableId: string) => void @@ -235,9 +235,11 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ } if (isRenaming) return onColumnSelect(colIndex, e.shiftKey) - if (!e.shiftKey) { - onOpenConfig(column.key) - } + } + + function handleHeaderDoubleClick() { + if (isRenaming || isWorkflowOutput) return + onRenameColumn?.(column.key) } function handleChevronClick(e: React.MouseEvent) { @@ -331,6 +333,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ type='button' className='flex min-w-0 flex-1 cursor-pointer items-center px-2 py-[7px] outline-none' onClick={handleHeaderClick} + onDoubleClick={handleHeaderDoubleClick} draggable={false} > ({ Pin: () => null, PinOff: () => null, PlayOutline: () => null, - Settings: () => null, SquareArrowUpRight: () => null, Trash: () => null, Workflow: () => null, @@ -74,11 +73,7 @@ afterEach(() => { container.remove() }) -function renderMenu( - column: ColumnDefinition, - onGoToReferenceTable: (tableId: string) => void, - onRenameColumn?: (columnName: string) => void -) { +function renderMenu(column: ColumnDefinition, onGoToReferenceTable: (tableId: string) => void) { act(() => { root.render( ) }) @@ -142,12 +136,9 @@ describe('ColumnOptionsMenu Reference navigation', () => { }) describe('ColumnOptionsMenu editing', () => { - it('starts inline rename from the column menu', () => { - const onRenameColumn = vi.fn() - renderMenu({ id: 'col-name', name: 'Name', type: 'string' }, vi.fn(), onRenameColumn) - - act(() => findButton('Rename column')?.click()) + it('keeps rename out of the column menu', () => { + renderMenu({ id: 'col-name', name: 'Name', type: 'string' }, vi.fn()) - expect(onRenameColumn).toHaveBeenCalledWith('col-name') + expect(findButton('Rename column')).toBeUndefined() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx index f0d0b279369..21f1d172bd8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx @@ -71,8 +71,6 @@ interface ColumnOptionsMenuProps { * it leaves the group with siblings). */ deleteLabel?: string onOpenConfig: (columnName: string) => void - /** Starts inline renaming for a plain or enrichment column. */ - onRenameColumn?: (columnName: string) => void /** Opens the table targeted by a Reference column. */ onGoToReferenceTable?: (tableId: string) => void onInsertLeft: (columnName: string) => void @@ -127,7 +125,6 @@ export function ColumnOptionsMenu({ column, deleteLabel, onOpenConfig, - onRenameColumn, onGoToReferenceTable, onInsertLeft, onInsertRight, @@ -246,12 +243,6 @@ export function ColumnOptionsMenu({ Edit column
- {onRenameColumn && ( - onRenameColumn(column.key)}> - - Rename column - - )} {onPinToggle && ( onPinToggle(column.key)}> {isPinned ? : }