From 7bf119c3ccd1102cd5cfdb946b2168e4c8f6f173 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/16] 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 30dd03797e3..de07ad91ebb 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -85,11 +85,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: { @@ -97,6 +99,7 @@ export function refineColumnOptions( options?: z.infer multiple?: boolean currencyCode?: string + referenceTableId?: string }, ctx: z.RefinementCtx ): void { @@ -110,6 +113,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({ @@ -222,6 +239,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.') @@ -304,6 +324,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.'), @@ -321,6 +344,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 95f1686b33a..3285b25f127 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 a63886a4a5c..13bbbd3c360 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 { zonedWallClockToUtc } from '@/lib/core/utils/timezone' import type { ColumnType } from '@/lib/table/column-types' @@ -43,6 +44,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) { @@ -317,19 +329,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', ({ @@ -346,6 +362,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 5a6e23791ea..7f747037c7c 100644 --- a/apps/sim/lib/table/column-types/registry.server.ts +++ b/apps/sim/lib/table/column-types/registry.server.ts @@ -290,6 +290,7 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record = { ttl: ttlColumnType, json: jsonColumnType, select: selectColumnType, + reference: referenceColumnType, currency: currencyColumnType, } @@ -113,9 +115,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 72edeead0d0..be2d80e5a01 100644 --- a/apps/sim/lib/table/column-types/types.ts +++ b/apps/sim/lib/table/column-types/types.ts @@ -40,6 +40,7 @@ export const COLUMN_TYPES = [ 'ttl', 'json', 'select', + 'reference', ] as const export type ColumnType = (typeof COLUMN_TYPES)[number] @@ -62,7 +63,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 f8259213a45..5e93340952f 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -164,6 +164,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 50ea7cfc3b2..6d9f979c17f 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -512,6 +512,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 98747c75ed6..f400ac9a88d 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. */ @@ -903,6 +908,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 e5fd89c3d2d..1d5faf5f37d 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -51,6 +51,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 89d15ca9c712edd394eca153cd9a66dc79539f05 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/16] 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 1e96164e1b5..3fd719ffc57 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(), @@ -197,11 +201,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, })) @@ -1682,6 +1688,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 d48ac344d42..89088ca71d1 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -967,6 +967,7 @@ export const userTableServerTool: BaseServerTool options?: unknown multiple?: boolean currencyCode?: string + referenceTableId?: string } | undefined if (!col?.name || !col?.type) { @@ -1090,17 +1091,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)) { @@ -1131,6 +1136,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 fa0a274d146..58837ae80c2 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -57,6 +57,7 @@ import type { UpdateColumnConstraintsData, UpdateColumnCurrencyData, UpdateColumnOptionsData, + UpdateColumnReferenceData, UpdateColumnTypeData, } from '@/lib/table/types' import { validateColumnDefinition } from '@/lib/table/validation' @@ -128,6 +129,7 @@ export async function addTableColumn( options?: SelectOption[] multiple?: boolean currencyCode?: string + referenceTableId?: string }, requestId: string, options?: ColumnMutationOptions @@ -181,6 +183,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), } @@ -904,7 +909,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', @@ -1470,6 +1476,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 f400ac9a88d..054608d8f63 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -962,6 +962,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 df3700eb236abd5b984a4ff22441dc34a1c862da 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/16] feat(tables): configure reference columns in sidebar --- .../column-config-sidebar.test.tsx | 235 ++++++++++++++++++ .../column-config-sidebar.tsx | 114 ++++++--- .../components/column-config-sidebar/index.ts | 2 +- .../[workspaceId]/tables/[tableId]/table.tsx | 1 - 4 files changed, 312 insertions(+), 40 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..d1d34db7ca5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx @@ -0,0 +1,235 @@ +/** + * @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 303742fdd2b..b2cde6086f1 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 @@ -6,6 +6,7 @@ import { X } from '@sim/emcn/icons' import { toError } from '@sim/utils/errors' import { findValidationIssue, isValidationError } from '@/lib/api/client/errors' import type { ColumnDefinition, SelectOption } from '@/lib/table' +import { columnTypeOf } from '@/lib/table/column-types' import { DEFAULT_CURRENCY_CODE, getCurrencyOptions, @@ -15,7 +16,7 @@ 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 { SelectOptionsEditor } from '../select-field' import { columnTypeOptionsForTable } from './column-types' @@ -56,9 +57,6 @@ interface ColumnConfigSidebarProps { tableRowTtlEnabled: boolean 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 } /** @@ -108,7 +106,6 @@ function ColumnConfigBody({ tableRowTtlEnabled, workspaceId, tableId, - onColumnRename, }: ColumnConfigBodyProps) { const updateColumn = useUpdateColumn({ workspaceId, tableId }) const addColumn = useAddTableColumn({ workspaceId, tableId }) @@ -133,14 +130,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. */ @@ -153,8 +160,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 } @@ -164,47 +176,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 } : {}), @@ -213,6 +226,9 @@ function ColumnConfigBody({ ...(wantsCurrency && (typeChanged || currencyChanged) ? { currencyCode: currencyInput } : {}), + ...(wantsReference && (typeChanged || referenceTableChanged) + ? { referenceTableId: referenceTableInput } + : {}), } if (Object.keys(updates).length === 0) { onClose() @@ -220,8 +236,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)) { @@ -254,23 +269,25 @@ 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' && ( <> @@ -341,8 +358,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 f5d7d9a197d..eac702088ef 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 d39a6f474d0..7e492cd0bac 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -1659,7 +1659,6 @@ export function Table({ } workspaceId={workspaceId} tableId={tableId} - onColumnRename={onColumnRename} /> Date: Tue, 25 Aug 2026 16:45:48 -0700 Subject: [PATCH 04/16] docs(tables): document reference columns --- apps/docs/content/docs/tables/index.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/docs/tables/index.mdx b/apps/docs/content/docs/tables/index.mdx index e44ca483464..bace3335a1d 100644 --- a/apps/docs/content/docs/tables/index.mdx +++ b/apps/docs/content/docs/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 0f66e330d249f32ec12a4eb02392b433f41056a7 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/16] 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 d1d34db7ca5..173fdcaadbd 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 @@ -164,6 +164,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 43f7c958bc8..3f64afcb6e4 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 @@ -25,7 +25,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' @@ -4110,11 +4110,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 de07ad91ebb..b732484a973 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, @@ -85,7 +86,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 @@ -93,7 +97,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 @@ -243,7 +247,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({ @@ -328,7 +332,7 @@ export const createTableColumnBodySchema = z.object({ .optional() .describe('Target table for a reference column.'), }) - .superRefine(refineColumnOptions) + .superRefine(refineColumnTypeMetadata) .describe('Typed column definition to add.'), }) @@ -348,7 +352,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 3285b25f127..a5c9640deea 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 58837ae80c2..e078b6c88ae 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -1531,7 +1531,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 56e1f25c05b..271f6c83cc5 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 e24cdd583b0a1df1d7891c483d9b45553c4d5103 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/16] 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 | 11 ++ .../components/table-grid/table-grid.tsx | 18 ++- 6 files changed, 279 insertions(+), 1 deletion(-) 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..10da7386259 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, @@ -70,6 +71,8 @@ interface ColumnOptionsMenuProps { * 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 3f64afcb6e4..f06ed681b2e 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 @@ -9,7 +9,7 @@ import type { TableCellSelection } from '@sim/realtime-protocol/table-presence' import { getErrorMessage } from '@sim/utils/errors' import { assessTextPaste, formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste' 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' @@ -478,6 +478,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 workspaceIdRef = useRef(workspaceId) workspaceIdRef.current = workspaceId const tableIdRef = useRef(tableId) @@ -1719,6 +1720,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 @@ -4883,6 +4897,7 @@ export function TableGrid({ workflowGroups={tableWorkflowGroups} sourceInfo={columnSourceInfo.get(column.key)} onOpenConfig={handleConfigureColumn} + onGoToReferenceTable={handleGoToReferenceTable} onViewWorkflow={handleViewWorkflow} onSortColumn={onSortColumn} onClearSort={onClearSort} @@ -5060,6 +5075,7 @@ export function TableGrid({ onInsertAbove={handleInsertRowAbove} onInsertBelow={handleInsertRowBelow} onDuplicate={handleDuplicateRow} + onCopyRowId={contextMenu.row ? handleCopyRowId : undefined} onViewExecution={handleViewExecution} canViewExecution={ (Boolean(contextMenuExecutionId) && contextMenuHasStartedRun) || From 5973ebed0e568d3b0ddc520597179ff7c657442c 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 07/16] 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 | 56 +++++++++- apps/sim/lib/table/service.ts | 2 + 11 files changed, 269 insertions(+), 21 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 bc79d37a27b..20670399a27 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 13bbbd3c360..7cce3b27347 100644 --- a/apps/sim/lib/table/__tests__/column-type-registry.test.ts +++ b/apps/sim/lib/table/__tests__/column-type-registry.test.ts @@ -49,8 +49,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 7f747037c7c..afc0c2f4a05 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 @@ -290,7 +291,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 be2d80e5a01..807b0c3e6ce 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 e078b6c88ae..0993f9b1727 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -30,6 +30,7 @@ import { valueForTypeConversion, } from '@/lib/table/column-types' import { + assertColumnReferencesInWorkspace, migrationFrom, migrationTo, writeBackCoercedCells, @@ -196,6 +197,7 @@ export async function addTableColumn( `Invalid column: ${columnValidation.errors.join('; ')}` ) } + await assertColumnReferencesInWorkspace(trx, table.workspaceId, [newColumn]) const newColumnId = getColumnId(newColumn) @@ -964,6 +966,7 @@ export async function updateColumnType( isSelectType, targetMultiple: !!targetMultiple, }) + await assertColumnReferencesInWorkspace(trx, table.workspaceId, [convertedColumn]) const renamedColumns = schema.columns.map((c, i) => (i === columnIndex ? convertedColumn : c)) const updatedColumns = renamedColumns.map((c, i) => i === columnIndex ? applyPendingRename(renamedColumns, columnIndex, data.newName) : c @@ -1480,8 +1483,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, @@ -1520,6 +1523,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 d3141b6394b..00ae4102236 100644 --- a/apps/sim/lib/table/service.test.ts +++ b/apps/sim/lib/table/service.test.ts @@ -12,8 +12,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DbOrTx } from '@/lib/db/types' import type { TableSchema } from '@/lib/table/types' -const { mockAssertTableRowTtlEnabled } = vi.hoisted(() => ({ - mockAssertTableRowTtlEnabled: vi.fn(), +const mocks = vi.hoisted(() => ({ + assertColumnReferencesInWorkspace: vi.fn(), + assertTableRowTtlEnabled: vi.fn(), +})) + +vi.mock('@/lib/table/column-types/registry.server', () => ({ + assertColumnReferencesInWorkspace: mocks.assertColumnReferencesInWorkspace, })) vi.mock('@/lib/realtime/notify', () => ({ @@ -26,7 +31,7 @@ vi.mock('@/lib/table/billing', () => ({ })) vi.mock('@/lib/table/ttl-availability', () => ({ - assertTableRowTtlEnabled: mockAssertTableRowTtlEnabled, + assertTableRowTtlEnabled: mocks.assertTableRowTtlEnabled, })) import { createTable, getTableById } from '@/lib/table/service' @@ -66,11 +71,14 @@ describe('createTable schema invariants', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockAssertTableRowTtlEnabled.mockResolvedValue(undefined) + mocks.assertColumnReferencesInWorkspace.mockResolvedValue(undefined) + mocks.assertTableRowTtlEnabled.mockResolvedValue(undefined) }) it('rejects a TTL schema before persistence when the feature is disabled', async () => { - mockAssertTableRowTtlEnabled.mockRejectedValue(new Error('Expiration columns are not enabled')) + mocks.assertTableRowTtlEnabled.mockRejectedValue( + new Error('Expiration columns are not enabled') + ) await expect( create({ columns: [{ name: 'expires_at', type: 'ttl' }] } as TableSchema) @@ -131,6 +139,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 36328223e2a..e0cd3bb9e52 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, @@ -628,6 +629,7 @@ export async function createTable( await trx.execute( sql`SELECT 1 FROM workspace WHERE id = ${data.workspaceId} FOR NO KEY UPDATE` ) + await assertColumnReferencesInWorkspace(trx, data.workspaceId, schema.columns) const [{ count: existingCount }] = await trx .select({ count: count() }) From 2cf12df10ba7743aae048f9f4a900e2edf6fa5d2 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:28:18 -0700 Subject: [PATCH 08/16] fix(tables): preserve explicit column undo metadata --- .../components/table-grid/table-grid.tsx | 11 ++++- apps/sim/hooks/use-table-undo.test.ts | 44 ++++++++++++++++--- apps/sim/hooks/use-table-undo.ts | 9 +++- apps/sim/stores/table/store.test.ts | 1 - apps/sim/stores/table/types.ts | 10 ++++- 5 files changed, 64 insertions(+), 11 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 f06ed681b2e..4ee4244edee 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 @@ -25,7 +25,7 @@ import type { WorkflowGroup, } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' -import { columnTypeOf, typeMetadataOf } from '@/lib/table/column-types' +import { columnTypeOf } 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' @@ -4124,7 +4124,14 @@ export function TableGrid({ columnPosition: adjustedPosition >= 0 ? adjustedPosition : cols.length, columnUnique: entry.def?.unique ?? false, columnRequired: entry.def?.required ?? false, - columnTypeMetadata: entry.def ? typeMetadataOf(entry.def) : {}, + // 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 } : {}), + ...(entry.def?.referenceTableId + ? { columnReferenceTableId: entry.def.referenceTableId } + : {}), 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 8e73702897e..670b8039921 100644 --- a/apps/sim/hooks/use-table-undo.test.ts +++ b/apps/sim/hooks/use-table-undo.test.ts @@ -195,7 +195,6 @@ describe('useTableUndo – delete-column undo cell restore chunking', () => { columnPosition: 0, columnUnique: false, columnRequired: false, - columnTypeMetadata: {}, cellData: [], previousOrder: null, previousWidth: null, @@ -249,10 +248,8 @@ describe('useTableUndo – restoring a deleted select column', () => { columnPosition: 0, columnUnique: false, columnRequired: false, - columnTypeMetadata: { - options: [{ id: 'opt_open', name: 'Open' }], - multiple: true, - }, + columnOptions: [{ id: 'opt_open', name: 'Open' }], + columnMultiple: true, cellData: [], previousOrder: null, previousWidth: null, @@ -276,6 +273,41 @@ describe('useTableUndo – restoring a deleted select column', () => { }) }) +describe('useTableUndo – restoring a deleted currency column', () => { + it('re-creates the column with its original denomination', async () => { + mockPopUndo.mockReturnValueOnce( + makeEntry({ + type: 'delete-column', + columnName: 'amount', + columnId: 'col_amount', + columnType: 'currency', + columnPosition: 0, + columnUnique: false, + columnRequired: false, + columnCurrencyCode: 'JPY', + cellData: [], + previousOrder: null, + previousWidth: null, + previousPinnedColumns: null, + }) + ) + + const { undo } = TestHook() + ;(undo as () => void)() + await flush() + + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'col_amount', + name: 'amount', + type: 'currency', + currencyCode: 'JPY', + }), + expect.any(Object) + ) + }) +}) + describe('useTableUndo – restoring a deleted reference column', () => { it('re-creates the column with its target table', async () => { mockPopUndo.mockReturnValueOnce( @@ -287,7 +319,7 @@ describe('useTableUndo – restoring a deleted reference column', () => { columnPosition: 0, columnUnique: false, columnRequired: false, - columnTypeMetadata: { referenceTableId: 'tbl_people' }, + columnReferenceTableId: 'tbl_people', cellData: [], previousOrder: null, previousWidth: null, diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 908b6d5464f..dc3bf6d96aa 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -386,7 +386,14 @@ export function useTableUndo({ type: action.columnType, required: action.columnRequired, unique: action.columnUnique, - ...action.columnTypeMetadata, + // 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.columnReferenceTableId + ? { referenceTableId: action.columnReferenceTableId } + : {}), position: action.columnPosition, }, { diff --git a/apps/sim/stores/table/store.test.ts b/apps/sim/stores/table/store.test.ts index b58cfcd4e75..e1acbe309ea 100644 --- a/apps/sim/stores/table/store.test.ts +++ b/apps/sim/stores/table/store.test.ts @@ -19,7 +19,6 @@ 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 84bd5a877e9..7d15c8f8b27 100644 --- a/apps/sim/stores/table/types.ts +++ b/apps/sim/stores/table/types.ts @@ -55,7 +55,15 @@ export type TableUndoAction = columnPosition: number columnUnique: boolean columnRequired: boolean - columnTypeMetadata: Partial + // 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 + columnReferenceTableId?: string cellData: Array<{ rowId: string; value: unknown }> previousOrder: string[] | null previousWidth: number | null From 644a5a4643abc65d75a83bf4ce58bae50848c383 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:29:44 -0700 Subject: [PATCH 09/16] fix(tables): align reference config with column registry --- .../column-config-sidebar/column-config-sidebar.test.tsx | 2 +- .../column-config-sidebar/column-config-sidebar.tsx | 4 ++-- .../[tableId]/components/column-config-sidebar/index.ts | 2 +- 3 files changed, 4 insertions(+), 4 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 173fdcaadbd..5d29e335a40 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 @@ -73,7 +73,7 @@ vi.mock('@/lib/table/column-types', () => ({ { id: 'select', label: 'Select', icon: () => null }, { id: 'reference', label: 'Reference', icon: () => null }, ], - columnTypeOf: (type: string) => ({ supportsUnique: type !== 'select' }), + columnTypeById: (type: string) => ({ supportsUnique: type !== 'select' }), })) vi.mock('@/hooks/queries/tables', () => ({ 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 b2cde6086f1..53ba661f611 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 @@ -6,7 +6,7 @@ import { X } from '@sim/emcn/icons' import { toError } from '@sim/utils/errors' import { findValidationIssue, isValidationError } from '@/lib/api/client/errors' import type { ColumnDefinition, SelectOption } from '@/lib/table' -import { columnTypeOf } from '@/lib/table/column-types' +import { columnTypeById } from '@/lib/table/column-types' import { DEFAULT_CURRENCY_CODE, getCurrencyOptions, @@ -143,7 +143,7 @@ function ColumnConfigBody({ const wantsOptions = isSelectType(typeInput) const wantsCurrency = typeInput === 'currency' const wantsReference = typeInput === 'reference' - const supportsUnique = columnTypeOf(typeInput).supportsUnique + const supportsUnique = columnTypeById(typeInput).supportsUnique const { data: workspaceTables = [] } = useTablesList(workspaceId, 'active', { enabled: wantsReference, }) 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 eac702088ef..f5d7d9a197d 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, ColumnConfigurationMetadata } from './column-config-sidebar' +export type { ColumnConfig } from './column-config-sidebar' export { ColumnConfigSidebar } from './column-config-sidebar' export { COLUMN_TYPE_OPTIONS, From 4d68fb3d3fac65a160d8e6aec33a95a6444a19f0 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:37:20 -0700 Subject: [PATCH 10/16] fix(tables): update generated reference contracts --- packages/sim-cli/src/generated/v2-api.ts | 190 +++++++++++++++++++++-- 1 file changed, 174 insertions(+), 16 deletions(-) diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index cab4ac6f8a8..3f0d7f285ee 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -181,7 +181,16 @@ export type AddTableColumnBody = { column: { id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required?: boolean unique?: boolean options?: Array<{ @@ -190,6 +199,7 @@ export type AddTableColumnBody = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string position?: number } } @@ -198,7 +208,16 @@ type AddTableColumnResponseRef0 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -208,6 +227,7 @@ type AddTableColumnResponseRef0 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } @@ -248,7 +268,16 @@ export type AddWorkflowGroupBody = { } outputColumns: Array<{ name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required?: boolean unique?: boolean }> @@ -283,7 +312,16 @@ type AddWorkflowGroupResponseRef1 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -293,6 +331,7 @@ type AddWorkflowGroupResponseRef1 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } @@ -1886,7 +1925,16 @@ export type CreateTableBody = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required?: boolean unique?: boolean options?: Array<{ @@ -1895,6 +1943,7 @@ export type CreateTableBody = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } folderPath?: CreateTableBodyRef0 @@ -1918,7 +1967,16 @@ type CreateTableResponseRef1 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -1928,6 +1986,7 @@ type CreateTableResponseRef1 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } rowCount: number @@ -2870,7 +2929,16 @@ type DeleteTableColumnResponseRef0 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -2880,6 +2948,7 @@ type DeleteTableColumnResponseRef0 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } @@ -3131,7 +3200,16 @@ type DeleteWorkflowGroupResponseRef0 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -3141,6 +3219,7 @@ type DeleteWorkflowGroupResponseRef0 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } @@ -4312,7 +4391,16 @@ type GetTableResponseRef1 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -4322,6 +4410,7 @@ type GetTableResponseRef1 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } rowCount: number @@ -6072,7 +6161,16 @@ type ListTablesResponseRef0 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -6082,6 +6180,7 @@ type ListTablesResponseRef0 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } rowCount: number @@ -7278,7 +7377,16 @@ type RestoreTableResponseRef1 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -7288,6 +7396,7 @@ type RestoreTableResponseRef1 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } rowCount: number @@ -8418,7 +8527,16 @@ type UpdateTableResponseRef1 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -8428,6 +8546,7 @@ type UpdateTableResponseRef1 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } rowCount: number @@ -8460,7 +8579,16 @@ export type UpdateTableColumnBody = { columnName: string updates: { name?: string - type?: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type?: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required?: boolean unique?: boolean options?: Array<{ @@ -8469,6 +8597,7 @@ export type UpdateTableColumnBody = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string } } @@ -8476,7 +8605,16 @@ type UpdateTableColumnResponseRef0 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -8486,6 +8624,7 @@ type UpdateTableColumnResponseRef0 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } @@ -8739,7 +8878,16 @@ export type UpdateWorkflowGroupBody = { }> newOutputColumns?: Array<{ name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required?: boolean unique?: boolean }> @@ -8785,7 +8933,16 @@ type UpdateWorkflowGroupResponseRef1 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -8795,6 +8952,7 @@ type UpdateWorkflowGroupResponseRef1 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } From 18bfe30785c841abcc0ff4c06bcaa03ed9a640bd Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:29:07 -0700 Subject: [PATCH 11/16] docs(tables): defer reference column documentation --- apps/docs/content/docs/tables/index.mdx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/docs/content/docs/tables/index.mdx b/apps/docs/content/docs/tables/index.mdx index bace3335a1d..e44ca483464 100644 --- a/apps/docs/content/docs/tables/index.mdx +++ b/apps/docs/content/docs/tables/index.mdx @@ -26,9 +26,8 @@ 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. 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. +Types are enforced as you enter values, so a Number column only takes numbers. 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 af5bd32b9b57158004eff1f255492b9f63252ed7 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:21:57 -0700 Subject: [PATCH 12/16] fix(tables): keep sidebar renaming in reference foundation --- .../column-config-sidebar.test.tsx | 21 ++++++-- .../column-config-sidebar.tsx | 48 +++++++++++-------- .../[workspaceId]/tables/[tableId]/table.tsx | 1 + 3 files changed, 45 insertions(+), 25 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 5d29e335a40..5745e92881c 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 @@ -104,6 +104,12 @@ function findButton(label: string): HTMLButtonElement | undefined { ) } +function setInputValue(input: HTMLInputElement, value: string): void { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + valueSetter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') @@ -184,7 +190,8 @@ describe('ColumnConfigSidebar', () => { expect(mockUpdateColumn).not.toHaveBeenCalled() }) - it('edits Reference configuration without exposing column renaming', async () => { + it('edits a Reference column name and target table together', async () => { + const onColumnRename = vi.fn() await act(async () => { root.render( { }} workspaceId='workspace-1' tableId='table-current' + onColumnRename={onColumnRename} /> ) }) - expect(container).not.toHaveTextContent('Column name') - expect(container.querySelector('#column-sidebar-name')).toBeNull() + const nameInput = container.querySelector('#column-sidebar-name') + expect(nameInput?.value).toBe('Related row') + act(() => setInputValue(nameInput!, 'Renamed relation')) act(() => findCombobox('Select table')?.onChange?.('table-customers')) await act(async () => findButton('Save')?.click()) expect(mockUpdateColumn).toHaveBeenCalledWith({ columnName: 'col-reference', - updates: { referenceTableId: 'table-customers' }, + updates: { + name: 'Renamed relation', + referenceTableId: 'table-customers', + }, }) + expect(onColumnRename).toHaveBeenCalledWith('col-reference', 'Renamed relation') }) it('keeps Select options in the edit sidebar', async () => { 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 53ba661f611..b33f950b233 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 @@ -57,6 +57,9 @@ interface ColumnConfigSidebarProps { tableRowTtlEnabled: boolean 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 } /** @@ -106,6 +109,7 @@ function ColumnConfigBody({ tableRowTtlEnabled, workspaceId, tableId, + onColumnRename, }: ColumnConfigBodyProps) { const updateColumn = useUpdateColumn({ workspaceId, tableId }) const addColumn = useAddTableColumn({ workspaceId, tableId }) @@ -166,7 +170,7 @@ function ColumnConfigBody({ } async function handleSave() { - if (config.mode === 'create' && !trimmedName) { + if (!trimmedName) { setShowValidation(true) return } @@ -198,6 +202,7 @@ function ColumnConfigBody({ return } + const renamed = trimmedName !== (existingColumn?.name ?? config.columnName) const typeChanged = !!existingColumn && existingColumn.type !== typeInput const uniqueChanged = supportsUnique && !!existingColumn && !!existingColumn.unique !== uniqueInput @@ -211,6 +216,7 @@ function ColumnConfigBody({ wantsReference && existingColumn?.referenceTableId !== referenceTableInput const updates: { + name?: string type?: ColumnDefinition['type'] unique?: boolean options?: SelectOption[] @@ -218,6 +224,7 @@ function ColumnConfigBody({ currencyCode?: string referenceTableId?: string } = { + ...(renamed ? { name: trimmedName } : {}), ...(typeChanged ? { type: typeInput } : {}), ...(uniqueChanged ? { unique: uniqueInput } : {}), ...(uniqueCleared ? { unique: false } : {}), @@ -236,7 +243,8 @@ function ColumnConfigBody({ } await updateColumn.mutateAsync({ columnName: config.columnName, updates }) - toast.success(`Saved "${existingColumn?.name ?? config.columnName}"`) + if (renamed) onColumnRename?.(config.columnName, trimmedName) + toast.success(`Saved "${trimmedName}"`) onClose() } catch (err) { if (isValidationError(err)) { @@ -269,25 +277,23 @@ function ColumnConfigBody({
- {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) && } -
- )} +
+ 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' && ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 7e492cd0bac..d39a6f474d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -1659,6 +1659,7 @@ export function Table({ } workspaceId={workspaceId} tableId={tableId} + onColumnRename={onColumnRename} /> Date: Wed, 2 Sep 2026 16:53:31 -0700 Subject: [PATCH 13/16] feat(tables): gate reference columns --- apps/sim/.env.example | 1 + .../api/table/[tableId]/columns/route.test.ts | 52 +++++++++++++++++- .../app/api/table/[tableId]/columns/route.ts | 4 ++ .../column-config-sidebar.test.tsx | 32 +++++++++++ .../column-config-sidebar.tsx | 25 +++++++-- .../column-dropdown/column-dropdown.test.tsx | 30 +++++++++++ .../column-dropdown/column-dropdown.tsx | 18 ++++--- .../components/table-grid/table-grid.tsx | 7 ++- .../[workspaceId]/tables/[tableId]/table.tsx | 6 +++ apps/sim/lib/api/contracts/tables.ts | 9 ++-- apps/sim/lib/api/contracts/v2/tables.ts | 7 ++- apps/sim/lib/api/contracts/workspaces.ts | 2 + apps/sim/lib/core/config/env.ts | 1 + .../sim/lib/core/config/feature-flags.test.ts | 23 ++++++++ apps/sim/lib/core/config/feature-flags.ts | 7 +++ .../table/columns/reference-metadata.test.ts | 54 +++++++++++++++++++ apps/sim/lib/table/columns/service.ts | 5 ++ .../table/reference-columns/availability.ts | 17 ++++++ apps/sim/lib/table/service.test.ts | 24 +++++++++ apps/sim/lib/table/service.ts | 4 ++ apps/sim/lib/workspaces/host-context.test.ts | 8 +++ apps/sim/lib/workspaces/host-context.ts | 5 +- helm/sim/values.yaml | 1 + scripts/check-openapi-specs.ts | 14 +++-- scripts/openapi/documents.test.ts | 6 ++- scripts/openapi/generator.test.ts | 39 ++++++++++++++ scripts/openapi/generator.ts | 36 +++++++++++++ 27 files changed, 414 insertions(+), 23 deletions(-) create mode 100644 apps/sim/lib/table/reference-columns/availability.ts diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 90762132025..63fd7d6aef7 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -203,6 +203,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # FORKING_ENABLED= # Workspace forks # CREDENTIAL_GROUPS= # Enterprise managed OAuth collections # TABLE_ROW_TTL= # Table TTL columns and expired-row cleanup +# TABLE_REFERENCE_COLUMNS= # Table Reference columns # KNOWLEDGE_MEMBER_ACCESS= # Per-member knowledge connectors and hybrid-by-default retrieval # ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only diff --git a/apps/sim/app/api/table/[tableId]/columns/route.test.ts b/apps/sim/app/api/table/[tableId]/columns/route.test.ts index 24830309efc..849ce8737e6 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.test.ts @@ -55,6 +55,13 @@ vi.mock('@/lib/table/wire', () => ({ vi.mock('@/app/api/table/utils', () => ({ accessError: () => new Response('denied', { status: 403 }), checkAccess: mockCheckAccess, + orchestrationErrorResponse: (error: unknown) => + error instanceof OrchestrationError + ? NextResponse.json( + { error: error.message }, + { status: statusForOrchestrationError(error.code) } + ) + : null, orchestrationOutcomeErrorResponse: ( outcome: { error?: string; errorCode?: OrchestrationErrorCode }, fallback: string @@ -73,7 +80,7 @@ import { type OrchestrationErrorCode, statusForOrchestrationError, } from '@/lib/core/orchestration/types' -import { PATCH } from '@/app/api/table/[tableId]/columns/route' +import { PATCH, POST } from '@/app/api/table/[tableId]/columns/route' const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' @@ -88,6 +95,49 @@ function patch(updates: Record) { ) } +function post(column: Record) { + return POST( + new NextRequest('http://localhost/api/table/t1/columns', { + method: 'POST', + body: JSON.stringify({ workspaceId: WORKSPACE_ID, column }), + headers: { 'content-type': 'application/json' }, + }), + { params: Promise.resolve({ tableId: 't1' }) } + ) +} + +describe('POST /api/table/[tableId]/columns — Reference feature gate', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ + ok: true, + table: { workspaceId: WORKSPACE_ID, schema: { columns: [] } }, + }) + }) + + it('returns 403 when Reference columns are disabled', async () => { + mockAddTableColumn.mockRejectedValue( + new OrchestrationError('forbidden', 'Reference columns are not enabled for this deployment') + ) + + const response = await post({ + name: 'Account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: 'Reference columns are not enabled for this deployment', + }) + }) +}) + describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index dff45ad6728..5c12f5e6245 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -17,6 +17,7 @@ import { normalizeColumn } from '@/lib/table/wire' import { accessError, checkAccess, + orchestrationErrorResponse, orchestrationOutcomeErrorResponse, rootErrorMessage, tableLockErrorResponse, @@ -69,6 +70,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum return validationErrorResponse(error, 'Invalid request data') } + const classified = orchestrationErrorResponse(error) + if (classified) return classified + const msg = rootErrorMessage(error) if ( msg.includes('already exists') || 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 5745e92881c..42fc807dd1a 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 @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' interface ComboboxOption { label: string value: string + disabled?: boolean } interface ComboboxProps { @@ -16,6 +17,7 @@ interface ComboboxProps { placeholder?: string searchable?: boolean searchPlaceholder?: string + disabled?: boolean onChange?: (value: string) => void } @@ -143,6 +145,7 @@ describe('ColumnConfigSidebar', () => { existingColumn={null} workspaceId='workspace-1' tableId='table-current' + referenceColumnsEnabled /> ) }) @@ -179,6 +182,7 @@ describe('ColumnConfigSidebar', () => { existingColumn={null} workspaceId='workspace-1' tableId='table-current' + referenceColumnsEnabled /> ) }) @@ -206,6 +210,7 @@ describe('ColumnConfigSidebar', () => { workspaceId='workspace-1' tableId='table-current' onColumnRename={onColumnRename} + referenceColumnsEnabled /> ) }) @@ -227,6 +232,32 @@ describe('ColumnConfigSidebar', () => { expect(onColumnRename).toHaveBeenCalledWith('col-reference', 'Renamed relation') }) + it('keeps an existing Reference column visible but not retargetable when disabled', async () => { + await act(async () => { + root.render( + + ) + }) + + expect(mockUseTablesList).toHaveBeenCalledWith('workspace-1', 'active', { enabled: false }) + expect(findCombobox('Select table')?.disabled).toBe(true) + expect(findCombobox('Select type')?.options).toContainEqual( + expect.objectContaining({ value: 'reference', disabled: true }) + ) + }) + it('keeps Select options in the edit sidebar', async () => { await act(async () => { root.render( @@ -241,6 +272,7 @@ describe('ColumnConfigSidebar', () => { }} workspaceId='workspace-1' tableId='table-current' + referenceColumnsEnabled /> ) }) 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 b33f950b233..92065568e46 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 @@ -55,6 +55,7 @@ interface ColumnConfigSidebarProps { existingColumn: ColumnDefinition | null allColumns: readonly ColumnDefinition[] tableRowTtlEnabled: boolean + referenceColumnsEnabled: boolean workspaceId: string tableId: string /** Notify parent of a rename so it can rewrite local `columnOrder` / @@ -107,6 +108,7 @@ function ColumnConfigBody({ existingColumn, allColumns, tableRowTtlEnabled, + referenceColumnsEnabled, workspaceId, tableId, onColumnRename, @@ -142,14 +144,20 @@ function ColumnConfigBody({ 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 referenceMutationBlocked = + !referenceColumnsEnabled && + wantsReference && + (config.mode === 'create' || + existingColumn?.type !== 'reference' || + existingColumn.referenceTableId !== referenceTableInput) + const saveDisabled = updateColumn.isPending || addColumn.isPending || referenceMutationBlocked const supportsUnique = columnTypeById(typeInput).supportsUnique const { data: workspaceTables = [] } = useTablesList(workspaceId, 'active', { - enabled: wantsReference, + enabled: wantsReference && referenceColumnsEnabled, }) const tableOptions = workspaceTables.map((table) => ({ value: table.id, label: table.name })) const trimmedOptions = optionsInput.map((o) => ({ ...o, name: o.name.trim() })) @@ -304,12 +312,20 @@ function ColumnConfigBody({ options={columnTypeOptionsForTable(allColumns, existingColumn, { tableRowTtlEnabled, }) - .filter((option) => option.type !== 'workflow') + .filter( + (option) => + option.type !== 'workflow' && + (referenceColumnsEnabled || + option.type !== 'reference' || + existingColumn?.type === 'reference') + ) .map((option) => ({ label: option.label, value: option.type, icon: option.icon, - disabled: option.disabledReason !== undefined, + disabled: + option.disabledReason !== undefined || + (!referenceColumnsEnabled && option.type === 'reference'), }))} value={typeInput} onChange={(v) => setTypeInput(v as ColumnDefinition['type'])} @@ -372,6 +388,7 @@ function ColumnConfigBody({ { setReferenceTableInput(value) if (referenceTableError) setReferenceTableError(null) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx index e4321a7eb59..b470bef5eaa 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx @@ -33,6 +33,7 @@ describe('ColumnDropdown', () => { tableRowTtlEnabled trigger='header' disabled={false} + referenceColumnsEnabled onPickType={vi.fn()} onPickWorkflow={vi.fn()} onPickEnrichment={onPickEnrichment} @@ -57,4 +58,33 @@ describe('ColumnDropdown', () => { act(() => items.at(-1)?.click()) expect(onPickEnrichment).toHaveBeenCalledOnce() }) + + it('omits Reference when the feature is disabled', () => { + act(() => { + root.render( + + ) + }) + act(() => { + container + .querySelector('button') + ?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + }) + + const labels = [...document.body.querySelectorAll('[role="menuitem"]')].map( + (item) => item.textContent + ) + expect(labels).not.toContain('Reference') + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.tsx index 2cb10c8af94..27a54f3d568 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.tsx @@ -27,6 +27,7 @@ interface ColumnDropdownProps { * the in-table column-header `` trigger. Same dropdown content either way. */ trigger: 'header' | 'inline-header' disabled: boolean + referenceColumnsEnabled: boolean onPickType: (type: ColumnDefinition['type']) => void onPickWorkflow: () => void onPickEnrichment: () => void @@ -84,6 +85,7 @@ export function ColumnDropdown({ tableRowTtlEnabled, trigger, disabled, + referenceColumnsEnabled, onPickType, onPickWorkflow, onPickEnrichment, @@ -126,13 +128,15 @@ export function ColumnDropdown({ {triggerButton} - {columnTypeOptionsForTable(columns, undefined, { tableRowTtlEnabled }).map((option) => { - const onSelect = - option.type === 'workflow' - ? onPickWorkflow - : () => onPickType(option.type as ColumnDefinition['type']) - return - })} + {columnTypeOptionsForTable(columns, undefined, { tableRowTtlEnabled }) + .filter((option) => referenceColumnsEnabled || option.type !== 'reference') + .map((option) => { + const onSelect = + option.type === 'workflow' + ? onPickWorkflow + : () => onPickType(option.type as ColumnDefinition['type']) + return + })} Enrichments 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 4ee4244edee..33932bc8cae 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 @@ -172,6 +172,7 @@ export interface SelectionSnapshot { interface TableGridProps { workspaceId?: string tableId?: string + referenceColumnsEnabled: boolean embedded?: boolean tableRowTtlEnabled: boolean /** Remote collaborators' cell selections, rendered as presence overlays. */ @@ -436,6 +437,7 @@ async function chunkBatchUpdates( export function TableGrid({ workspaceId: propWorkspaceId, tableId: propTableId, + referenceColumnsEnabled, embedded, tableRowTtlEnabled, remoteSelections, @@ -4904,7 +4906,9 @@ export function TableGrid({ workflowGroups={tableWorkflowGroups} sourceInfo={columnSourceInfo.get(column.key)} onOpenConfig={handleConfigureColumn} - onGoToReferenceTable={handleGoToReferenceTable} + onGoToReferenceTable={ + referenceColumnsEnabled ? handleGoToReferenceTable : undefined + } onViewWorkflow={handleViewWorkflow} onSortColumn={onSortColumn} onClearSort={onClearSort} @@ -4924,6 +4928,7 @@ export function TableGrid({ tableRowTtlEnabled={tableRowTtlEnabled} trigger='inline-header' disabled={addColumnMutation.isPending} + referenceColumnsEnabled={referenceColumnsEnabled} blocked={!canMutateSchema} onBlocked={() => onBlockedAction('add-column')} onPickType={handleAddColumnOfType} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index d39a6f474d0..3f90d87d679 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -40,6 +40,7 @@ import { PresenceAvatars } from '@/app/workspace/[workspaceId]/components/presen import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components' import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { getTableViewRevision, @@ -194,6 +195,8 @@ export function Table({ const router = useRouter() const workspaceId = propWorkspaceId || (params.workspaceId as string) const tableId = propTableId || (params.tableId as string) + const hostContext = useOptionalWorkspaceHostContext() + const referenceColumnsEnabled = hostContext?.features?.referenceColumns ?? false const posthog = usePostHog() const tableRowTtlEnabled = useFeatureFlag('table-row-ttl') @@ -1379,6 +1382,7 @@ export function Table({ tableRowTtlEnabled={tableRowTtlEnabled} trigger='header' disabled={false} + referenceColumnsEnabled={referenceColumnsEnabled} blocked={!canMutateSchema} onBlocked={() => showBlockedToast('add-column')} onPickType={handleAddColumnOfType} @@ -1542,6 +1546,7 @@ export function Table({ () => z.custom(isRecordLike) */ export const columnTypeSchema = z .enum(COLUMN_TYPES) - .meta({ omitEnumValuesFromOpenApi: ['ttl'] as const }) + .meta({ omitEnumValuesFromOpenApi: ['ttl', 'reference'] as const }) /** One choice in a `select` column. `id` is the stable cell key. */ export const selectOptionSchema = z.object({ @@ -249,6 +249,7 @@ export const tableColumnSchema = z }) .superRefine(refineColumnTypeMetadata) .describe('A typed column in a table schema.') + .meta({ omitPropertiesFromOpenApi: ['referenceTableId'] as const }) export const createTableBodySchema = z.object({ name: tableNameSchema.describe('Table name.'), @@ -333,7 +334,8 @@ export const createTableColumnBodySchema = z.object({ .describe('Target table for a reference column.'), }) .superRefine(refineColumnTypeMetadata) - .describe('Typed column definition to add.'), + .describe('Typed column definition to add.') + .meta({ omitPropertiesFromOpenApi: ['referenceTableId'] as const }), }) export const updateTableColumnBodySchema = z.object({ @@ -353,7 +355,8 @@ export const updateTableColumnBodySchema = z.object({ .describe('New target table for a reference column.'), }) .superRefine(refineColumnTypeMetadata) - .describe('Column fields to update.'), + .describe('Column fields to update.') + .meta({ omitPropertiesFromOpenApi: ['referenceTableId'] as const }), }) export const deleteTableColumnBodySchema = z.object({ diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index a5c9640deea..4816f9e5725 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -494,6 +494,7 @@ export const v2TableColumnInputSchema = z .object(v2TableColumnInputShape) .strict() .superRefine(refineColumnTypeMetadata) + .meta({ omitPropertiesFromOpenApi: ['referenceTableId'] as const }) /** * Initial columns take the same shape as every other v2 column input. @@ -745,7 +746,8 @@ export const v2CreateTableColumnBodySchema = z }) .strict() .superRefine(refineColumnTypeMetadata) - .describe('Column definition to add.'), + .describe('Column definition to add.') + .meta({ omitPropertiesFromOpenApi: ['referenceTableId'] as const }), }) .strict() @@ -780,7 +782,8 @@ export const v2UpdateTableColumnBodySchema = z }) .strict() .superRefine(refineColumnTypeMetadata) - .describe('Mutable column fields.'), + .describe('Mutable column fields.') + .meta({ omitPropertiesFromOpenApi: ['referenceTableId'] as const }), }) .strict() diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 883c9375c0c..3875455c693 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -264,6 +264,8 @@ export const workspaceHostContextSchema = z.object({ credentialGroups: z.boolean(), /** Optional for rolling compatibility with app versions that predate the flag. */ knowledgeMemberAccess: z.boolean().optional(), + /** Optional for rolling compatibility with app versions that predate the Reference gate. */ + referenceColumns: z.boolean().optional(), }) .optional(), }) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index bd28ef56842..f260d75a17f 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -588,6 +588,7 @@ export const env = createEnv({ FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements) TABLES_V2_API: z.boolean().optional(), // Enable the v2 tables HTTP API (public /api/v2/tables + internal /api/table/[tableId]/query predicate-grammar route) TABLE_ROW_TTL: z.boolean().optional(), + TABLE_REFERENCE_COLUMNS: z.boolean().optional(), CREDENTIAL_GROUPS: z.boolean().optional(), // Enable enterprise Credential Groups globally KNOWLEDGE_MEMBER_ACCESS: z.boolean().optional(), // Enable per-member knowledge connectors and hybrid-by-default retrieval globally diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index 2d28ffa4cf4..1bd1e6b9f6b 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -13,6 +13,7 @@ const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({ APPCONFIG_ENVIRONMENT: 'staging' as string | undefined, TABLES_V2_API: undefined as boolean | undefined, TABLE_ROW_TTL: undefined as boolean | undefined, + TABLE_REFERENCE_COLUMNS: undefined as boolean | undefined, CREDENTIAL_GROUPS: undefined as boolean | undefined, KNOWLEDGE_MEMBER_ACCESS: undefined as boolean | undefined, }, @@ -80,6 +81,7 @@ describe('getFeatureFlags', () => { expect(flags['trigger-eu-region']).toEqual({ enabled: false }) expect(flags['tables-v2-api']).toEqual({ enabled: false }) expect(flags['table-row-ttl']).toEqual({ enabled: false }) + expect(flags['table-reference-columns']).toEqual({ enabled: false }) expect(flags['credential-groups']).toEqual({ enabled: false }) expect(mockFetch).not.toHaveBeenCalled() }) @@ -108,6 +110,7 @@ describe('getFeatureFlags', () => { expect(flags['trigger-eu-region']).toEqual({ enabled: false }) expect(flags['tables-v2-api']).toEqual({ enabled: false }) expect(flags['table-row-ttl']).toEqual({ enabled: false }) + expect(flags['table-reference-columns']).toEqual({ enabled: false }) expect(flags['credential-groups']).toEqual({ enabled: false }) }) @@ -296,3 +299,23 @@ describe('table-row-ttl flag', () => { expect(await isFeatureEnabled('table-row-ttl')).toBe(true) }) }) + +describe('table-reference-columns flag', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isAppConfigEnabled: false }) + envRef.TABLE_REFERENCE_COLUMNS = undefined + }) + + it('uses a global fallback switch off AppConfig', async () => { + expect(await isFeatureEnabled('table-reference-columns')).toBe(false) + + envRef.TABLE_REFERENCE_COLUMNS = true + expect(await isFeatureEnabled('table-reference-columns')).toBe(true) + }) + + it('uses the global AppConfig clause', async () => { + withAppConfig({ 'table-reference-columns': { enabled: true } }) + expect(await isFeatureEnabled('table-reference-columns')).toBe(true) + }) +}) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index bf6de543ba9..f56847a9230 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -68,6 +68,13 @@ const FEATURE_FLAGS = { 'Global on/off only; existing TTL data remains readable when disabled.', fallback: 'TABLE_ROW_TTL', }, + 'table-reference-columns': { + description: + 'Gate creation, conversion, and retargeting of table Reference columns plus their ' + + 'picker and navigation UI. Existing Reference data remains readable and writable when ' + + 'disabled. Off-AppConfig falls back to TABLE_REFERENCE_COLUMNS.', + fallback: 'TABLE_REFERENCE_COLUMNS', + }, 'credential-groups': { description: 'Workspace-owned collections that gather managed OAuth credentials from external users. ' + diff --git a/apps/sim/lib/table/columns/reference-metadata.test.ts b/apps/sim/lib/table/columns/reference-metadata.test.ts index 2d66d297e33..fa61729fd91 100644 --- a/apps/sim/lib/table/columns/reference-metadata.test.ts +++ b/apps/sim/lib/table/columns/reference-metadata.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ migrationFrom: vi.fn(), migrationTo: vi.fn(), writeBackCoercedCells: vi.fn(), + assertTableReferenceColumnsEnabled: vi.fn(), set: vi.fn(), where: vi.fn(), })) @@ -23,6 +24,9 @@ vi.mock('@/lib/table/column-types/registry.server', () => ({ migrationTo: mocks.migrationTo, writeBackCoercedCells: mocks.writeBackCoercedCells, })) +vi.mock('@/lib/table/reference-columns/availability', () => ({ + assertTableReferenceColumnsEnabled: mocks.assertTableReferenceColumnsEnabled, +})) import { addTableColumn, @@ -64,6 +68,7 @@ describe('reference column metadata persistence', () => { mocks.migrationFrom.mockReturnValue(undefined) mocks.migrationTo.mockReturnValue(undefined) mocks.writeBackCoercedCells.mockResolvedValue(undefined) + mocks.assertTableReferenceColumnsEnabled.mockResolvedValue(undefined) mocks.where.mockResolvedValue(undefined) mocks.set.mockReturnValue({ where: mocks.where }) }) @@ -108,6 +113,55 @@ describe('reference column metadata persistence', () => { ) }) + it('rejects Reference creation before locking when the feature is disabled', async () => { + mocks.assertTableReferenceColumnsEnabled.mockRejectedValueOnce({ code: 'forbidden' }) + + await expect( + addTableColumn( + 'tbl_people', + { name: 'Account', type: 'reference', referenceTableId: 'tbl_accounts' }, + 'req_1' + ) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.withLockedTable).not.toHaveBeenCalled() + }) + + it('rejects conversion to Reference before locking when the feature is disabled', async () => { + mocks.assertTableReferenceColumnsEnabled.mockRejectedValueOnce({ code: 'forbidden' }) + + await expect( + updateColumnType( + { + tableId: 'tbl_people', + columnName: 'col_name', + newType: 'reference', + referenceTableId: 'tbl_accounts', + }, + 'req_1' + ) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.withLockedTable).not.toHaveBeenCalled() + }) + + it('rejects Reference retargeting before locking when the feature is disabled', async () => { + mocks.assertTableReferenceColumnsEnabled.mockRejectedValueOnce({ code: 'forbidden' }) + + await expect( + updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_account', + referenceTableId: 'tbl_companies', + }, + 'req_1' + ) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.withLockedTable).not.toHaveBeenCalled() + }) + it('retains the supplied target when converting a column to reference', async () => { useTable(BASE_TABLE) diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 0993f9b1727..06566c5c4e9 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -39,6 +39,7 @@ import { COLUMN_TYPES, getMaxRowSizeBytes, NAME_PATTERN, TABLE_LIMITS } from '@/ import { resolveCurrencyCode } from '@/lib/table/currency' import { assertColumnDestructive, assertSchemaMutable } from '@/lib/table/mutation-locks' import type { DbTransaction } from '@/lib/table/planner' +import { assertTableReferenceColumnsEnabled } from '@/lib/table/reference-columns/availability' import { stripGroupExecutions } from '@/lib/table/rows/executions' import { updateTableRowsWithDerivedSecretProvenance } from '@/lib/table/rows/secret-provenance' import { assertValidSchema } from '@/lib/table/schema-invariants' @@ -136,6 +137,7 @@ export async function addTableColumn( options?: ColumnMutationOptions ): Promise { if (column.type === 'ttl') await assertTableRowTtlEnabled() + if (column.type === 'reference') await assertTableReferenceColumnsEnabled() return withLockedTable( tableId, @@ -868,6 +870,7 @@ export async function updateColumnType( options?: ColumnMutationOptions ): Promise { if (data.newType === 'ttl') await assertTableRowTtlEnabled() + if (data.newType === 'reference') await assertTableReferenceColumnsEnabled() return withLockedTable( data.tableId, @@ -1491,6 +1494,8 @@ export async function updateColumnReference( requestId: string, options?: ColumnMutationOptions ): Promise { + await assertTableReferenceColumnsEnabled() + return withLockedTable( data.tableId, async (table, trx) => { diff --git a/apps/sim/lib/table/reference-columns/availability.ts b/apps/sim/lib/table/reference-columns/availability.ts new file mode 100644 index 00000000000..d50c9a5327b --- /dev/null +++ b/apps/sim/lib/table/reference-columns/availability.ts @@ -0,0 +1,17 @@ +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +export const TABLE_REFERENCE_COLUMNS_DISABLED_MESSAGE = + 'Reference columns are not enabled for this deployment' + +/** Resolves the global runtime gate for Reference column behavior. */ +export function areTableReferenceColumnsEnabled(): Promise { + return isFeatureEnabled('table-reference-columns') +} + +/** Rejects mutations that introduce or reconfigure a Reference column. */ +export async function assertTableReferenceColumnsEnabled(): Promise { + if (!(await areTableReferenceColumnsEnabled())) { + throw new OrchestrationError('forbidden', TABLE_REFERENCE_COLUMNS_DISABLED_MESSAGE) + } +} diff --git a/apps/sim/lib/table/service.test.ts b/apps/sim/lib/table/service.test.ts index 00ae4102236..0250581ab55 100644 --- a/apps/sim/lib/table/service.test.ts +++ b/apps/sim/lib/table/service.test.ts @@ -14,6 +14,7 @@ import type { TableSchema } from '@/lib/table/types' const mocks = vi.hoisted(() => ({ assertColumnReferencesInWorkspace: vi.fn(), + assertTableReferenceColumnsEnabled: vi.fn(), assertTableRowTtlEnabled: vi.fn(), })) @@ -21,6 +22,10 @@ vi.mock('@/lib/table/column-types/registry.server', () => ({ assertColumnReferencesInWorkspace: mocks.assertColumnReferencesInWorkspace, })) +vi.mock('@/lib/table/reference-columns/availability', () => ({ + assertTableReferenceColumnsEnabled: mocks.assertTableReferenceColumnsEnabled, +})) + vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceTablesChanged: vi.fn().mockResolvedValue(undefined), })) @@ -72,6 +77,7 @@ describe('createTable schema invariants', () => { vi.clearAllMocks() resetDbChainMock() mocks.assertColumnReferencesInWorkspace.mockResolvedValue(undefined) + mocks.assertTableReferenceColumnsEnabled.mockResolvedValue(undefined) mocks.assertTableRowTtlEnabled.mockResolvedValue(undefined) }) @@ -160,6 +166,24 @@ describe('createTable schema invariants', () => { ) }) + it('rejects a Reference schema before opening a transaction when the feature is disabled', async () => { + mocks.assertTableReferenceColumnsEnabled.mockRejectedValueOnce({ code: 'forbidden' }) + + await expect( + create({ + columns: [ + { + name: 'account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }, + ], + } as TableSchema) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + }) + it('does not insert a table when a Reference target is unavailable', async () => { mocks.assertColumnReferencesInWorkspace.mockRejectedValueOnce({ code: 'not_found' }) diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index e0cd3bb9e52..2a1669b8453 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -53,6 +53,7 @@ import { import { assertSchemaMutable, TableLockedError } from '@/lib/table/mutation-locks' import { nKeysBetween } from '@/lib/table/order-key' import type { DbTransaction } from '@/lib/table/planner' +import { assertTableReferenceColumnsEnabled } from '@/lib/table/reference-columns/availability' import { createExactEmptyTableRowSecretProvenance, mutateTableRowsWithSecretProvenance, @@ -565,6 +566,9 @@ export async function createTable( if (data.schema.columns.some((column) => column.type === 'ttl')) { await assertTableRowTtlEnabled() } + if (data.schema.columns.some((column) => column.type === 'reference')) { + await assertTableReferenceColumnsEnabled() + } const tableId = `tbl_${generateId().replace(/-/g, '')}` const now = new Date() diff --git a/apps/sim/lib/workspaces/host-context.test.ts b/apps/sim/lib/workspaces/host-context.test.ts index 19cc0e1cfe8..2a34c4568ca 100644 --- a/apps/sim/lib/workspaces/host-context.test.ts +++ b/apps/sim/lib/workspaces/host-context.test.ts @@ -7,10 +7,12 @@ const { mockCheckWorkspaceAccess, mockGetWorkspaceOwnerSubscriptionAccess, mockGetOrganizationSettingsAccess, + mockAreTableReferenceColumnsEnabled, } = vi.hoisted(() => ({ mockCheckWorkspaceAccess: vi.fn(), mockGetWorkspaceOwnerSubscriptionAccess: vi.fn(), mockGetOrganizationSettingsAccess: vi.fn(), + mockAreTableReferenceColumnsEnabled: vi.fn(), })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -25,6 +27,10 @@ vi.mock('@/lib/billing/core/workspace-access', () => ({ getWorkspaceOwnerSubscriptionAccess: mockGetWorkspaceOwnerSubscriptionAccess, })) +vi.mock('@/lib/table/reference-columns/availability', () => ({ + areTableReferenceColumnsEnabled: mockAreTableReferenceColumnsEnabled, +})) + import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' const OWNER_BILLING = { @@ -67,6 +73,7 @@ describe('getWorkspaceHostContextForViewer', () => { beforeEach(() => { vi.clearAllMocks() mockGetWorkspaceOwnerSubscriptionAccess.mockResolvedValue(OWNER_BILLING) + mockAreTableReferenceColumnsEnabled.mockResolvedValue(true) }) it('returns host membership and route permission for an internal member', async () => { @@ -83,6 +90,7 @@ describe('getWorkspaceHostContextForViewer', () => { expect.objectContaining({ workspace: expect.objectContaining({ allowPersonalApiKeys: false }), hostOrganizationId: 'org-host', + features: expect.objectContaining({ referenceColumns: true }), viewer: { permission: 'write', isHostOrganizationMember: true, diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts index 78cd140507f..e701e54dfbe 100644 --- a/apps/sim/lib/workspaces/host-context.ts +++ b/apps/sim/lib/workspaces/host-context.ts @@ -4,6 +4,7 @@ import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspac import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access' +import { areTableReferenceColumnsEnabled } from '@/lib/table/reference-columns/availability' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' /** @@ -23,11 +24,12 @@ async function resolveWorkspaceHostContextForViewer( } const hostOrganizationId = access.workspace.organizationId - const [ownerBilling, hostOrganizationAccess] = await Promise.all([ + const [ownerBilling, hostOrganizationAccess, referenceColumnsEnabled] = await Promise.all([ getWorkspaceOwnerSubscriptionAccess(workspaceId), hostOrganizationId ? getOrganizationSettingsAccess(hostOrganizationId, userId) : Promise.resolve({ role: null, isMember: false, isAdmin: false }), + areTableReferenceColumnsEnabled(), ]) const [credentialGroupsAvailable, knowledgeMemberAccessAvailable] = await Promise.all([ isCredentialGroupsAvailable({ workspaceId, ownerBilling }), @@ -53,6 +55,7 @@ async function resolveWorkspaceHostContextForViewer( features: { credentialGroups: credentialGroupsAvailable, knowledgeMemberAccess: knowledgeMemberAccessAvailable, + referenceColumns: referenceColumnsEnabled, }, } } diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index ad31f63d6b0..f754dc2a688 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -114,6 +114,7 @@ app: # Generate using: openssl rand -hex 32 CRON_SECRET: "" # OPTIONAL - required only if cronjobs.enabled=true, authenticates scheduled job requests TABLE_ROW_TTL: "" # Enable TTL columns and expired-row cleanup when AppConfig is unavailable + TABLE_REFERENCE_COLUMNS: "" # Enable Reference columns when AppConfig is unavailable KNOWLEDGE_MEMBER_ACCESS: "" # Enable per-member knowledge connectors when AppConfig is unavailable # Optional: API Key Encryption (RECOMMENDED for production) diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts index 9e5126a982c..6603d5a7627 100644 --- a/scripts/check-openapi-specs.ts +++ b/scripts/check-openapi-specs.ts @@ -851,6 +851,14 @@ function diffSchemaFields( const zodNames = docPropertyNames(zodObj, zodRoot) const docNames = docPropertyNames(docObj, docRoot) if (!zodNames || !docNames) return + const omittedProperties = Array.isArray((zodObj as Json).omitPropertiesFromOpenApi) + ? new Set( + ((zodObj as Json).omitPropertiesFromOpenApi as unknown[]).filter( + (property): property is string => typeof property === 'string' + ) + ) + : new Set() + const visibleZodNames = new Set([...zodNames].filter((name) => !omittedProperties.has(name))) const fieldPath = (n: string) => (prefix ? `${prefix}.${n}` : n) /** * A `.passthrough()` contract deliberately under-declares its fields, so the @@ -859,7 +867,7 @@ function diffSchemaFields( const extra = (zodObj as Json).additionalProperties const zodIsPassthrough = extra === true || (!!extra && typeof extra === 'object' && Object.keys(extra).length === 0) - for (const n of zodNames) { + for (const n of visibleZodNames) { if (!docNames.has(n)) { fail( ctx.specFile, @@ -868,14 +876,14 @@ function diffSchemaFields( } } for (const n of docNames) { - if (!zodNames.has(n) && !zodIsPassthrough) { + if (!visibleZodNames.has(n) && !zodIsPassthrough) { fail( ctx.specFile, `${ctx.label}: documented ${ctx.where} field "${fieldPath(n)}" does not exist on ${ctx.name}` ) } } - for (const n of zodNames) { + for (const n of visibleZodNames) { if (!docNames.has(n)) continue diffSchemaFields( propertyNode(zodObj, zodRoot, n), diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index c7c3b510584..ba5319fb72c 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -280,7 +280,11 @@ describe('generated OpenAPI documents', () => { }) it('omits feature-flagged table column types', () => { - expect(JSON.stringify(generatedDocument(tablesOpenApiDocument))).not.toContain('"ttl"') + const serializedDocument = JSON.stringify(generatedDocument(tablesOpenApiDocument)) + + expect(serializedDocument).not.toContain('"ttl"') + expect(serializedDocument).not.toContain('"reference"') + expect(serializedDocument).not.toContain('"referenceTableId"') }) it('keeps billing as its own API reference group', () => { diff --git a/scripts/openapi/generator.test.ts b/scripts/openapi/generator.test.ts index 14fbd811396..d10335aee76 100644 --- a/scripts/openapi/generator.test.ts +++ b/scripts/openapi/generator.test.ts @@ -223,6 +223,45 @@ describe('OpenAPI generator', () => { expect(documentedColumnType).not.toHaveProperty('omitEnumValuesFromOpenApi') }) + it('omits feature-flagged properties from generated schemas', () => { + const body = z + .object({ + name: z.string().describe('Visible name.'), + unreleasedSetting: z.string().optional().describe('Unreleased setting.'), + }) + .meta({ + id: 'HiddenPropertyRequest', + title: 'Hidden property request', + description: 'Request body.', + omitPropertiesFromOpenApi: ['unreleasedSetting'], + }) + const response = z.object({ ok: z.boolean().describe('Whether the request succeeded.') }).meta({ + id: 'HiddenPropertyResponse', + title: 'Hidden property response', + description: 'Response.', + }) + const contract = defineRouteContract({ + method: 'POST', + path: '/hidden-property', + body, + response: { mode: 'json', schema: response }, + }) + const route = defineOpenApiRoute( + contract, + operation('hiddenProperty', { description: 'Response.' }), + { body, response } + ) + const spec = generateOpenApiDocument(document([route])) + const schemas = (spec.components as JsonObject).schemas as JsonObject + const documentedBody = schemas.HiddenPropertyRequest as JsonObject + const requestProperties = documentedBody.properties as JsonObject + + expect(body.safeParse({ name: 'Example', unreleasedSetting: 'enabled' }).success).toBe(true) + expect(requestProperties).toHaveProperty('name') + expect(requestProperties).not.toHaveProperty('unreleasedSetting') + expect(documentedBody).not.toHaveProperty('omitPropertiesFromOpenApi') + }) + it('handles every route response mode and media type', () => { const emptyContract = defineRouteContract({ method: 'DELETE', diff --git a/scripts/openapi/generator.ts b/scripts/openapi/generator.ts index 93fb3bb762b..6003830b105 100644 --- a/scripts/openapi/generator.ts +++ b/scripts/openapi/generator.ts @@ -130,6 +130,41 @@ function omitEnumValuesFromOpenApi( Reflect.deleteProperty(schema, 'omitEnumValuesFromOpenApi') } +function omitPropertiesFromOpenApi( + metadata: z.core.GlobalMeta | undefined, + schema: JsonObject, + label: string +): void { + const omittedProperties = metadata?.omitPropertiesFromOpenApi + if (omittedProperties === undefined) return + + invariant( + Array.isArray(omittedProperties) && omittedProperties.length > 0, + `${label} omitPropertiesFromOpenApi must be a non-empty array` + ) + invariant( + schema.properties !== undefined && + typeof schema.properties === 'object' && + !Array.isArray(schema.properties), + `${label} omitPropertiesFromOpenApi requires an object schema` + ) + + const properties = schema.properties as JsonObject + for (const property of omittedProperties) { + invariant( + typeof property === 'string' && Object.hasOwn(properties, property), + `${label} omits an object property that does not exist` + ) + Reflect.deleteProperty(properties, property) + } + + if (Array.isArray(schema.required)) { + schema.required = schema.required.filter((property) => !omittedProperties.includes(property)) + if (schema.required.length === 0) Reflect.deleteProperty(schema, 'required') + } + Reflect.deleteProperty(schema, 'omitPropertiesFromOpenApi') +} + function comparableSchema(schema: ApiSchema, io: SchemaIo): unknown { const cached = comparableSchemaCache.get(schema)?.get(io) if (cached) return cached @@ -237,6 +272,7 @@ function generateSchema( const schemaLabel = `${label} at ${path.join('.') || ''}` validateExamples(current, metadata?.examples, io, schemaLabel) omitEnumValuesFromOpenApi(metadata, jsonSchema as JsonObject, schemaLabel) + omitPropertiesFromOpenApi(metadata, jsonSchema as JsonObject, schemaLabel) }, }) as JsonObject const byIo = generatedSchemaCache.get(schema) ?? new Map() From 47b4e73db494c1847f0e400e179d7a66a7316640 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:18:37 -0700 Subject: [PATCH 14/16] fix(helm): bump chart version --- helm/sim/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/sim/Chart.yaml b/helm/sim/Chart.yaml index 690a816b28b..8d2f4372bd9 100644 --- a/helm/sim/Chart.yaml +++ b/helm/sim/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: sim description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents type: application -version: 1.8.0 +version: 1.8.1 appVersion: "v0.8.18" kubeVersion: ">=1.25.0-0" home: https://sim.ai From 618c89a1d7d22fc25c7bf9d74d2f0c9a8f53bf43 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:45:02 -0700 Subject: [PATCH 15/16] fix(tables): address reference review findings --- .../column-config-sidebar.test.tsx | 31 +++- .../column-config-sidebar.tsx | 18 +- .../column-dropdown/column-dropdown.test.tsx | 7 +- .../components/table-grid/table-grid.tsx | 5 +- .../lib/copilot/generated/tool-catalog-v1.ts | 147 +++++++++++++++- .../lib/copilot/generated/tool-schemas-v1.ts | 159 +++++++++++++++++- apps/sim/lib/table/import.test.ts | 3 + apps/sim/lib/table/import.ts | 4 +- 8 files changed, 353 insertions(+), 21 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 42fc807dd1a..6dea17a12ee 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 @@ -18,6 +18,7 @@ interface ComboboxProps { searchable?: boolean searchPlaceholder?: string disabled?: boolean + isLoading?: boolean onChange?: (value: string) => void } @@ -124,6 +125,7 @@ beforeEach(() => { { id: 'table-current', name: 'Current table' }, { id: 'table-customers', name: 'Customers' }, ], + isPending: false, }) mockAddColumn.mockResolvedValue({ data: { columns: [] } }) mockUpdateColumn.mockResolvedValue({ data: { columns: [] } }) @@ -233,6 +235,8 @@ describe('ColumnConfigSidebar', () => { }) it('keeps an existing Reference column visible but not retargetable when disabled', async () => { + mockUseTablesList.mockReturnValue({ data: [], isPending: false }) + await act(async () => { root.render( { ) }) - expect(mockUseTablesList).toHaveBeenCalledWith('workspace-1', 'active', { enabled: false }) - expect(findCombobox('Select table')?.disabled).toBe(true) + expect(mockUseTablesList).toHaveBeenCalledWith('workspace-1', 'active', { enabled: true }) + expect(findCombobox('Select table')).toMatchObject({ + disabled: true, + options: [{ label: 'table-current', value: 'table-current' }], + value: 'table-current', + }) expect(findCombobox('Select type')?.options).toContainEqual( expect.objectContaining({ value: 'reference', disabled: true }) ) }) + it('shows the Reference table selector as loading while tables are fetched', async () => { + mockUseTablesList.mockReturnValue({ data: [], isPending: true }) + + await act(async () => { + root.render( + + ) + }) + + expect(findCombobox('Select table')?.isLoading).toBe(true) + }) + it('keeps Select options in the edit sidebar', async () => { await act(async () => { root.render( 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 92065568e46..a43855e1a0e 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 @@ -156,10 +156,19 @@ function ColumnConfigBody({ existingColumn.referenceTableId !== referenceTableInput) const saveDisabled = updateColumn.isPending || addColumn.isPending || referenceMutationBlocked const supportsUnique = columnTypeById(typeInput).supportsUnique - const { data: workspaceTables = [] } = useTablesList(workspaceId, 'active', { - enabled: wantsReference && referenceColumnsEnabled, - }) - const tableOptions = workspaceTables.map((table) => ({ value: table.id, label: table.name })) + const shouldLoadReferenceTables = + wantsReference && (referenceColumnsEnabled || existingColumn?.type === 'reference') + const { data: workspaceTables = [], isPending: workspaceTablesPending } = useTablesList( + workspaceId, + 'active', + { enabled: shouldLoadReferenceTables } + ) + const tableOptions = [ + ...workspaceTables.map((table) => ({ value: table.id, label: table.name })), + ...(referenceTableInput && !workspaceTables.some((table) => table.id === referenceTableInput) + ? [{ value: referenceTableInput, label: referenceTableInput }] + : []), + ] const trimmedOptions = optionsInput.map((o) => ({ ...o, name: o.name.trim() })) /** Client-side option validation mirroring the server rules; returns an error message or null. */ @@ -396,6 +405,7 @@ function ColumnConfigBody({ placeholder='Select table' searchable searchPlaceholder='Search tables' + isLoading={shouldLoadReferenceTables && workspaceTablesPending} maxHeight={260} /> {referenceTableError && } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx index b470bef5eaa..140b5c7ad2c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx @@ -85,6 +85,11 @@ describe('ColumnDropdown', () => { const labels = [...document.body.querySelectorAll('[role="menuitem"]')].map( (item) => item.textContent ) - expect(labels).not.toContain('Reference') + expect(labels).toEqual([ + ...COLUMN_TYPE_OPTIONS.filter((option) => option.type !== 'reference').map( + (option) => option.label + ), + 'Enrichments', + ]) }) }) 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 33932bc8cae..82457145a8b 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 @@ -1725,7 +1725,10 @@ export function TableGrid({ function handleCopyRowId() { const rowId = contextMenu.row?.id if (!rowId) return - void navigator.clipboard.writeText(rowId).catch(() => {}) + void navigator.clipboard.writeText(rowId).catch((error) => { + logger.error('Failed to copy row ID', { error }) + toast.error('Failed to copy row ID') + }) } const handleGoToReferenceTable = useCallback( diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 0fa9d09d64a..72981f0048d 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -5473,7 +5473,54 @@ export const TableColumns: ToolCatalogEntry = { column: { type: 'object', description: - 'Column definition for add_column: { name, type, unique?, position? }; type may be string, number, boolean, date, json, select, or ttl. Select (enum) columns also take { options: [names], multiple?: true } — options is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Column definition for add_column: { name, type, unique?, position? }. Type may be string, number, currency, boolean, date, json, select, ttl, or reference. Currency optionally takes currencyCode; select takes { options: [names], multiple?: true }; reference requires referenceTableId. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + properties: { + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, + multiple: { + type: 'boolean', + description: + 'Whether a select cell may hold several options (default false). Switching true → false fails if any row has more than one selected.', + }, + name: { type: 'string' }, + options: { + type: 'array', + description: + 'Choices for a select (enum) column as display names, e.g. ["Open", "Closed"]. Required when creating or converting to select. On update_column this REPLACES the whole list, matched BY NAME — send the full list including options you keep; omitting one deletes it and clears its cells. Max 100.', + items: { type: 'string' }, + }, + position: { type: 'integer' }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, + type: { + type: 'string', + description: + 'Column type for add_column: string, number, currency, boolean, date, json, select, ttl, or reference.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'ttl', + 'reference', + ], + }, + unique: { + type: 'boolean', + description: + 'Set or clear the column unique constraint (update_column; not supported on select columns)', + }, + }, + required: ['name', 'type'], }, columnName: { type: 'string', @@ -5485,6 +5532,11 @@ export const TableColumns: ToolCatalogEntry = { description: 'Array of column names to delete at once (preferred for multi-column delete_column)', }, + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, multiple: { type: 'boolean', description: @@ -5494,7 +5546,18 @@ export const TableColumns: ToolCatalogEntry = { newType: { type: 'string', description: - 'New column type for update_column: string, number, boolean, date, json, select, ttl. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.', + 'New column type for update_column: string, number, currency, boolean, date, json, select, ttl, reference. Converting to currency optionally takes currencyCode; converting to reference requires referenceTableId; converting to select requires options and fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'ttl', + 'reference', + ], }, options: { type: 'array', @@ -5507,6 +5570,11 @@ export const TableColumns: ToolCatalogEntry = { description: 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, tableId: { type: 'string', description: 'Table ID (required for every operation)' }, unique: { type: 'boolean', @@ -5673,7 +5741,7 @@ export const TableManage: ToolCatalogEntry = { schema: { type: 'object', description: - 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; types are string, number, boolean, date, json, select, and ttl. A select (enum) column also requires options (display names) and takes multiple?. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; types are string, number, currency, boolean, date, json, select, ttl, and reference. Currency takes currencyCode?, reference requires referenceTableId, and select requires options (display names) and takes multiple?. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', }, tableId: { type: 'string', @@ -6079,7 +6147,53 @@ export const UserTable: ToolCatalogEntry = { column: { type: 'object', description: - 'Column definition for add_column: { name, type, unique?, position? }. Type may be string, number, boolean, date, json, select, or ttl. For a select (enum) column also pass { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Column definition for add_column: { name, type, unique?, position? }. Type may be string, number, currency, boolean, date, json, select, ttl, or reference. Currency optionally takes currencyCode; select takes { options: ["Open", "Closed"], multiple?: true }; reference requires referenceTableId. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + properties: { + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, + multiple: { + type: 'boolean', + description: + 'Whether a select (enum) cell may hold several options (default false). Switching an existing column from true to false fails if any row has more than one option selected.', + }, + name: { type: 'string' }, + options: { + type: 'array', + description: + 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', + items: { type: 'string' }, + }, + position: { type: 'integer' }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, + type: { + type: 'string', + description: + 'Column type for add_column: string, number, currency, boolean, date, json, select, ttl, or reference.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'ttl', + 'reference', + ], + }, + unique: { + type: 'boolean', + description: 'Set column unique constraint (optional for update_column)', + }, + }, + required: ['name', 'type'], }, columnName: { type: 'string', @@ -6091,6 +6205,11 @@ export const UserTable: ToolCatalogEntry = { description: 'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.', }, + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, cursor: { type: 'string', description: @@ -6217,7 +6336,18 @@ export const UserTable: ToolCatalogEntry = { newType: { type: 'string', description: - 'New column type (optional for update_column). Types: string, number, boolean, date, json, select, ttl. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.', + 'New column type (optional for update_column). Types: string, number, currency, boolean, date, json, select, ttl, reference. Converting to currency optionally takes currencyCode; converting to reference requires referenceTableId; converting to select requires options and fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'ttl', + 'reference', + ], }, options: { type: 'array', @@ -6282,6 +6412,11 @@ export const UserTable: ToolCatalogEntry = { description: 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below that index shift down. Omit to append at the end.', }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, rowId: { type: 'string', description: @@ -6307,7 +6442,7 @@ export const UserTable: ToolCatalogEntry = { schema: { type: 'object', description: - 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. Types are string, number, boolean, date, json, select, and ttl. A select (enum) column also takes { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. Types are string, number, currency, boolean, date, json, select, ttl, and reference. Currency optionally takes currencyCode; select takes { options: ["Open", "Closed"], multiple?: true }; reference requires referenceTableId. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', }, scope: { type: 'string', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 25cd935add7..dc8cae184d6 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -5348,7 +5348,60 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { column: { type: 'object', description: - 'Column definition for add_column: { name, type, unique?, position? }; type may be string, number, boolean, date, json, select, or ttl. Select (enum) columns also take { options: [names], multiple?: true } — options is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Column definition for add_column: { name, type, unique?, position? }. Type may be string, number, currency, boolean, date, json, select, ttl, or reference. Currency optionally takes currencyCode; select takes { options: [names], multiple?: true }; reference requires referenceTableId. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + properties: { + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, + multiple: { + type: 'boolean', + description: + 'Whether a select cell may hold several options (default false). Switching true → false fails if any row has more than one selected.', + }, + name: { + type: 'string', + }, + options: { + type: 'array', + description: + 'Choices for a select (enum) column as display names, e.g. ["Open", "Closed"]. Required when creating or converting to select. On update_column this REPLACES the whole list, matched BY NAME — send the full list including options you keep; omitting one deletes it and clears its cells. Max 100.', + items: { + type: 'string', + }, + }, + position: { + type: 'integer', + }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, + type: { + type: 'string', + description: + 'Column type for add_column: string, number, currency, boolean, date, json, select, ttl, or reference.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'ttl', + 'reference', + ], + }, + unique: { + type: 'boolean', + description: + 'Set or clear the column unique constraint (update_column; not supported on select columns)', + }, + }, + required: ['name', 'type'], }, columnName: { type: 'string', @@ -5360,6 +5413,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Array of column names to delete at once (preferred for multi-column delete_column)', }, + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, multiple: { type: 'boolean', description: @@ -5372,7 +5430,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { newType: { type: 'string', description: - 'New column type for update_column: string, number, boolean, date, json, select, ttl. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.', + 'New column type for update_column: string, number, currency, boolean, date, json, select, ttl, reference. Converting to currency optionally takes currencyCode; converting to reference requires referenceTableId; converting to select requires options and fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'ttl', + 'reference', + ], }, options: { type: 'array', @@ -5387,6 +5456,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, tableId: { type: 'string', description: 'Table ID (required for every operation)', @@ -5578,7 +5652,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { schema: { type: 'object', description: - 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; types are string, number, boolean, date, json, select, and ttl. A select (enum) column also requires options (display names) and takes multiple?. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; types are string, number, currency, boolean, date, json, select, ttl, and reference. Currency takes currencyCode?, reference requires referenceTableId, and select requires options (display names) and takes multiple?. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', }, tableId: { type: 'string', @@ -6008,7 +6082,59 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { column: { type: 'object', description: - 'Column definition for add_column: { name, type, unique?, position? }. Type may be string, number, boolean, date, json, select, or ttl. For a select (enum) column also pass { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Column definition for add_column: { name, type, unique?, position? }. Type may be string, number, currency, boolean, date, json, select, ttl, or reference. Currency optionally takes currencyCode; select takes { options: ["Open", "Closed"], multiple?: true }; reference requires referenceTableId. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + properties: { + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, + multiple: { + type: 'boolean', + description: + 'Whether a select (enum) cell may hold several options (default false). Switching an existing column from true to false fails if any row has more than one option selected.', + }, + name: { + type: 'string', + }, + options: { + type: 'array', + description: + 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', + items: { + type: 'string', + }, + }, + position: { + type: 'integer', + }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, + type: { + type: 'string', + description: + 'Column type for add_column: string, number, currency, boolean, date, json, select, ttl, or reference.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'ttl', + 'reference', + ], + }, + unique: { + type: 'boolean', + description: 'Set column unique constraint (optional for update_column)', + }, + }, + required: ['name', 'type'], }, columnName: { type: 'string', @@ -6020,6 +6146,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.', }, + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, cursor: { type: 'string', description: @@ -6159,7 +6290,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { newType: { type: 'string', description: - 'New column type (optional for update_column). Types: string, number, boolean, date, json, select, ttl. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.', + 'New column type (optional for update_column). Types: string, number, currency, boolean, date, json, select, ttl, reference. Converting to currency optionally takes currencyCode; converting to reference requires referenceTableId; converting to select requires options and fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'ttl', + 'reference', + ], }, options: { type: 'array', @@ -6232,6 +6374,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below that index shift down. Omit to append at the end.', }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, rowId: { type: 'string', description: @@ -6259,7 +6406,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { schema: { type: 'object', description: - 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. Types are string, number, boolean, date, json, select, and ttl. A select (enum) column also takes { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. Types are string, number, currency, boolean, date, json, select, ttl, and reference. Currency optionally takes currencyCode; select takes { options: ["Open", "Closed"], multiple?: true }; reference requires referenceTableId. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', }, scope: { type: 'string', diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts index 5e93340952f..a9f7781ea8b 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -167,6 +167,9 @@ describe('import', () => { 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') + expect(coerceValue(true, 'reference')).toBe('true') + expect(coerceValue(['row_1', 'row_2'], 'reference')).toBeNull() + expect(coerceValue({ id: 'row_1' }, 'reference')).toBeNull() }) it('keeps date-only values as calendar dates, preserves datetime wall times with their offset, and falls back to the original string', () => { diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 6d9f979c17f..73d8912d268 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -513,7 +513,9 @@ export function coerceValue( } } case 'reference': - return String(value) + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' + ? String(value) + : null default: return String(value) } From c6f653aabe67962adcad9cd3b4af56106797ae79 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:01:34 -0700 Subject: [PATCH 16/16] fix(tables): keep reference navigation available to viewers --- .../column-config-sidebar.test.tsx | 12 ++++ .../table-grid/headers/column-header-menu.tsx | 17 ++++- .../headers/workflow-group-meta-cell.test.tsx | 64 ++++++++++++++++++- 3 files changed, 90 insertions(+), 3 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 6dea17a12ee..5e55cb2baed 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 @@ -145,6 +145,8 @@ describe('ColumnConfigSidebar', () => { config={{ mode: 'create', proposedName: 'Related row', type: 'reference' }} onClose={vi.fn()} existingColumn={null} + allColumns={[]} + tableRowTtlEnabled={false} workspaceId='workspace-1' tableId='table-current' referenceColumnsEnabled @@ -182,6 +184,8 @@ describe('ColumnConfigSidebar', () => { config={{ mode: 'create', proposedName: 'Related row', type: 'reference' }} onClose={vi.fn()} existingColumn={null} + allColumns={[]} + tableRowTtlEnabled={false} workspaceId='workspace-1' tableId='table-current' referenceColumnsEnabled @@ -209,6 +213,8 @@ describe('ColumnConfigSidebar', () => { type: 'reference', referenceTableId: 'table-current', }} + allColumns={[]} + tableRowTtlEnabled={false} workspaceId='workspace-1' tableId='table-current' onColumnRename={onColumnRename} @@ -248,6 +254,8 @@ describe('ColumnConfigSidebar', () => { type: 'reference', referenceTableId: 'table-current', }} + allColumns={[]} + tableRowTtlEnabled={false} workspaceId='workspace-1' tableId='table-current' referenceColumnsEnabled={false} @@ -275,6 +283,8 @@ describe('ColumnConfigSidebar', () => { config={{ mode: 'create', proposedName: 'Related row', type: 'reference' }} onClose={vi.fn()} existingColumn={null} + allColumns={[]} + tableRowTtlEnabled={false} workspaceId='workspace-1' tableId='table-current' referenceColumnsEnabled @@ -297,6 +307,8 @@ describe('ColumnConfigSidebar', () => { type: 'select', options: [{ id: 'option-ready', name: 'Ready' }], }} + allColumns={[]} + tableRowTtlEnabled={false} workspaceId='workspace-1' tableId='table-current' referenceColumnsEnabled 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 3a524cba847..659acc145a9 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 @@ -1,8 +1,8 @@ 'use client' import React, { useCallback, useEffect, useRef, useState } from 'react' -import { cn } from '@sim/emcn' -import { ChevronDown } from '@sim/emcn/icons' +import { Button, cn } from '@sim/emcn' +import { ChevronDown, SquareArrowUpRight } from '@sim/emcn/icons' import type { SortDirection, WorkflowGroup } from '@/lib/table' import { HeaderLabel } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/header-label' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -252,6 +252,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ // Column whose workflow source block was deleted — the header icon swaps to // `WorkflowX` with an explanatory tooltip. const blockMissing = Boolean(sourceInfo?.blockMissing) + const referenceTableId = column.type === 'reference' ? column.referenceTableId : undefined return ( + {referenceTableId && onGoToReferenceTable && ( + + )}
) : (
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 20670399a27..8454b78ce5f 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 @@ -1,13 +1,19 @@ /** * @vitest-environment jsdom */ -import { act, type ReactNode } from 'react' +import { act, type ButtonHTMLAttributes, 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', () => ({ + Button: ({ children, ...props }: ButtonHTMLAttributes) => ( + + ), cn: (...values: Array) => values.filter(Boolean).join(' '), + FloatingTooltip: () => null, + isTextClipped: () => false, + useFloatingTooltip: () => ({ state: {}, handlers: {} }), DropdownMenu: ({ children, open }: { children: ReactNode; open: boolean }) => open ? <>{children} : null, DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, @@ -28,6 +34,7 @@ vi.mock('@sim/emcn/icons', () => ({ ArrowLeft: () => null, ArrowRight: () => null, ArrowUp: () => null, + ChevronDown: () => null, Eye: () => null, EyeOff: () => null, Fingerprint: () => null, @@ -39,10 +46,12 @@ vi.mock('@sim/emcn/icons', () => ({ SquareArrowUpRight: () => null, Trash: () => null, Workflow: () => null, + WorkflowX: () => null, X: () => null, })) vi.mock('@/lib/table/column-types', () => ({ + columnTypeById: () => ({ icon: () => null }), columnTypeOf: (column: ColumnDefinition) => ({ icon: () => null, label: column.type === 'reference' ? 'Reference' : 'Text', @@ -55,6 +64,7 @@ vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config vi.mock('@/enrichments/registry', () => ({ getEnrichment: () => undefined })) +import { ColumnHeaderMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu' import { ColumnOptionsMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell' let container: HTMLDivElement @@ -134,3 +144,55 @@ describe('ColumnOptionsMenu Reference navigation', () => { expect(findButton('Go to Reference Table')).toBeUndefined() }) }) + +describe('ColumnHeaderMenu read-only Reference navigation', () => { + it('keeps a direct navigation action available without exposing the options menu', () => { + const onGoToReferenceTable = vi.fn() + + act(() => { + root.render( + + ) + }) + + const navigationButton = container.querySelector( + 'button[aria-label="Go to Reference Table"]' + ) + expect(navigationButton).not.toBeNull() + + act(() => navigationButton?.click()) + + expect(onGoToReferenceTable).toHaveBeenCalledWith('table-accounts') + expect(container.querySelector('button[aria-label="Column options"]')).toBeNull() + }) +})