From 34548738949e8faa7f34295bbadeb056797744da Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 11:12:14 -0700 Subject: [PATCH 01/24] refactor(tables): drive column metadata updates from the registry Adding a type-specific metadata key needed ~9 near-identical edits, none compiler-enforced: a bespoke service writer, a branch in both column routes, the copilot tool, and a per-key validity check duplicated in each. The add-column-type skill lists this as its known gap. One writer now handles every key. `updateColumnMetadata` replaces `updateColumnCurrency`, reading ownership from `ownedMetadata`, normalization from `defaultMetadata`, and validation from `validateDefinition`, so callers name no keys at all. Routes route on `metadataKeysIn(updates)` rather than testing `updates.currencyCode !== undefined`, and `UpdateColumnTypeData` derives its metadata slice from `ColumnDefinition` so `buildConvertedColumn`'s indexed read fails to compile until a new key is carriable. Types whose metadata changes the stored bytes declare `migrateCellsForMetadata` and get a scaled-timeout rewrite inside the same transaction; presentational metadata still touches no row. The update path's hand-written "Invalid currency code" message is gone in favour of the currency type's own, which the add-column path already returned. --- .../api/table/[tableId]/columns/route.test.ts | 35 +++--- .../app/api/table/[tableId]/columns/route.ts | 59 ++++------ .../api/v1/tables/[tableId]/columns/route.ts | 59 ++++------ .../copilot/tools/server/table/user-table.ts | 89 ++++++++------ .../lib/table/column-types/registry.server.ts | 8 ++ apps/sim/lib/table/column-types/registry.ts | 71 +++++++++++- apps/sim/lib/table/column-types/select.ts | 4 + .../lib/table/column-types/types.server.ts | 12 ++ apps/sim/lib/table/column-types/types.ts | 44 +++++++ apps/sim/lib/table/columns/metadata.ts | 62 ++++++++++ apps/sim/lib/table/columns/service.ts | 109 ++++++++++++------ apps/sim/lib/table/types.ts | 38 ++++-- 12 files changed, 427 insertions(+), 163 deletions(-) create mode 100644 apps/sim/lib/table/columns/metadata.ts 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 4ac282861cd..a5dc93232ed 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.test.ts @@ -17,7 +17,7 @@ const { mockCheckAccess, mockRenameColumn, mockUpdateColumnType, - mockUpdateColumnCurrency, + mockUpdateColumnMetadata, mockUpdateColumnOptions, mockUpdateColumnConstraints, mockAddTableColumn, @@ -26,7 +26,7 @@ const { mockCheckAccess: vi.fn(), mockRenameColumn: vi.fn(), mockUpdateColumnType: vi.fn(), - mockUpdateColumnCurrency: vi.fn(), + mockUpdateColumnMetadata: vi.fn(), mockUpdateColumnOptions: vi.fn(), mockUpdateColumnConstraints: vi.fn(), mockAddTableColumn: vi.fn(), @@ -38,7 +38,7 @@ vi.mock('@/lib/table', () => ({ deleteColumn: mockDeleteColumn, renameColumn: mockRenameColumn, updateColumnConstraints: mockUpdateColumnConstraints, - updateColumnCurrency: mockUpdateColumnCurrency, + updateColumnMetadata: mockUpdateColumnMetadata, updateColumnOptions: mockUpdateColumnOptions, updateColumnType: mockUpdateColumnType, })) @@ -92,7 +92,7 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => { }) // The whole point: the rename must not have been committed. expect(mockRenameColumn).not.toHaveBeenCalled() - expect(mockUpdateColumnCurrency).not.toHaveBeenCalled() + expect(mockUpdateColumnMetadata).not.toHaveBeenCalled() }) it('rejects an unsupported currency code without renaming first', async () => { @@ -107,14 +107,17 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => { const response = await patch({ name: 'renamed', currencyCode: 'ZZZ' }) expect(response.status).toBe(400) + // The currency type's own `validateDefinition` message — the same one the + // add-column path already returned. The route no longer carries a second, + // differently-worded copy of this check. expect(await response.json()).toMatchObject({ - error: expect.stringContaining('Invalid currency code'), + error: 'Column "amount" has invalid currency code "ZZZ". Use an ISO 4217 code, e.g. USD', }) expect(mockRenameColumn).not.toHaveBeenCalled() }) it('still applies a rename when the currency it rides on is unchanged', async () => { - // `updateColumnCurrency` no-ops on an unchanged code. The rename folded into + // `updateColumnMetadata` no-ops on an unchanged code. The rename folded into // the same request must not be dropped with it. mockCheckAccess.mockResolvedValue({ ok: true, @@ -125,13 +128,13 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => { }, }, }) - mockUpdateColumnCurrency.mockResolvedValue({ schema: { columns: [] } }) + mockUpdateColumnMetadata.mockResolvedValue({ schema: { columns: [] } }) const response = await patch({ name: 'renamed', currencyCode: 'USD' }) expect(response.status).toBe(200) - expect(mockUpdateColumnCurrency).toHaveBeenCalledWith( - expect.objectContaining({ currencyCode: 'USD', newName: 'renamed' }), + expect(mockUpdateColumnMetadata).toHaveBeenCalledWith( + expect.objectContaining({ metadata: { currencyCode: 'USD' }, newName: 'renamed' }), expect.any(String) ) }) @@ -158,7 +161,7 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => { }) // Stands in for the race the guards cannot close: the column stopped being // a currency between the snapshot the guards read and this write. - mockUpdateColumnCurrency.mockRejectedValue( + mockUpdateColumnMetadata.mockRejectedValue( new Error('Cannot set currency on column "amount" of type "string"') ) @@ -189,7 +192,7 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => { error: expect.stringContaining('already exists'), }) // The typed write would otherwise have committed under a rename that fails. - expect(mockUpdateColumnCurrency).not.toHaveBeenCalled() + expect(mockUpdateColumnMetadata).not.toHaveBeenCalled() expect(mockRenameColumn).not.toHaveBeenCalled() }) @@ -303,7 +306,7 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => { schema: { columns: [{ id: 'col_a', name: 'amount', type: 'currency' }] }, }, }) - mockUpdateColumnCurrency.mockResolvedValue({ schema: { columns: [] } }) + mockUpdateColumnMetadata.mockResolvedValue({ schema: { columns: [] } }) const response = await patch({ name: 'renamed', currencyCode: 'eur' }) @@ -311,9 +314,13 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => { // One transaction, not two: the rename rides along with the currency write, // so neither half can commit without the other. expect(mockRenameColumn).not.toHaveBeenCalled() - expect(mockUpdateColumnCurrency).toHaveBeenCalledWith( + expect(mockUpdateColumnMetadata).toHaveBeenCalledWith( // Addressed by stable id; the contract upper-cases the code on the way in. - expect.objectContaining({ columnName: 'col_a', currencyCode: 'EUR', newName: 'renamed' }), + expect.objectContaining({ + columnName: 'col_a', + metadata: { currencyCode: 'EUR' }, + newName: 'renamed', + }), expect.any(String) ) }) diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index abda376ede7..bc84052250b 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -15,13 +15,13 @@ import { deleteColumn, renameColumn, updateColumnConstraints, - updateColumnCurrency, + updateColumnMetadata, updateColumnOptions, updateColumnType, } from '@/lib/table' import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' -import { columnTypeById } from '@/lib/table/column-types' -import { isSupportedCurrencyCode } from '@/lib/table/currency' +import { columnTypeById, metadataKeysIn, pickMetadata } from '@/lib/table/column-types' +import { validateMetadataUpdate } from '@/lib/table/columns/metadata' import { signalTableSchemaChanged } from '@/lib/table/events' import { accessError, @@ -145,14 +145,17 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu ) } + // Which type-specific keys this payload carries, and which writer owns + // each. Read from the registry rather than named here, so a new metadata + // key routes correctly without touching this route. + const { generic: genericMetadataKeys, dedicated: dedicatedMetadataKeys } = + metadataKeysIn(updates) + // A retype applies and validates the constraints itself, so the separate // constraint write only runs when the type is unchanged. The rename rides // whichever write actually runs last. const typedWriteRuns = - typeChanging || - updates.currencyCode !== undefined || - updates.options !== undefined || - updates.multiple !== undefined + typeChanging || genericMetadataKeys.length > 0 || dedicatedMetadataKeys.length > 0 const constraintsWriteRuns = !typedWriteRuns && (updates.required !== undefined || updates.unique !== undefined) const renameWithTypedWrite = @@ -165,23 +168,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu // changing: an options-only update on an existing select column carries the // same hazard as a conversion does. const resultingType = updates.type ?? currentColumn?.type - if (updates.currencyCode !== undefined) { - if (resultingType !== 'currency') { - return NextResponse.json( - { - error: `Cannot set currency on column "${validated.columnName}" of type "${resultingType}"`, - }, - { status: 400 } - ) - } - if (!isSupportedCurrencyCode(updates.currencyCode)) { - return NextResponse.json( - { - error: `Invalid currency code "${updates.currencyCode}". Use an ISO 4217 code, e.g. USD`, - }, - { status: 400 } - ) - } + const metadataError = validateMetadataUpdate(currentColumn, resultingType, updates) + if (metadataError) { + return NextResponse.json({ error: metadataError }, { status: 400 }) } // The rename runs last (see below), so a name already taken would fail after // the typed write committed. This is the only rename failure a caller can @@ -224,9 +213,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu tableId, columnName: columnRef, newType: updates.type as NonNullable, - ...(updates.options !== undefined ? { options: updates.options } : {}), - ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), - ...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}), + // Every type-specific key the payload carries, whichever writer would + // own it standalone: a conversion applies its target's metadata in the + // same transaction rather than leaving it to a second write. + ...pickMetadata(updates, [...genericMetadataKeys, ...dedicatedMetadataKeys]), // Forwarded so the conversion validates against the constraint this // same request is about to set, not the column's current one. ...(updates.required !== undefined ? { required: updates.required } : {}), @@ -235,22 +225,23 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu }, requestId ) - } else if (updates.currencyCode !== undefined) { - // Re-denominating an existing currency column: schema-only, no cell - // rewrite. Reached only when the type is unchanged — a conversion INTO - // currency carries the code through `updateColumnType` above. - updatedTable = await updateColumnCurrency( + } else if (genericMetadataKeys.length > 0) { + // Changing a column's own metadata — re-denominating a currency, changing + // a number's precision. Usually schema-only; the type declares a cell + // rewrite if it needs one. Reached only when the type is unchanged, since + // a conversion carries its metadata through `updateColumnType` above. + updatedTable = await updateColumnMetadata( { tableId, columnName: columnRef, - currencyCode: updates.currencyCode, + metadata: pickMetadata(updates, genericMetadataKeys), ...(updates.required !== undefined ? { required: updates.required } : {}), ...(updates.unique !== undefined ? { unique: updates.unique } : {}), ...renameWithTypedWrite, }, requestId ) - } else if (updates.options !== undefined || updates.multiple !== undefined) { + } else if (dedicatedMetadataKeys.length > 0) { updatedTable = await updateColumnOptions( { tableId, diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index b5471842a6c..b2293476a83 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -14,13 +14,13 @@ import { deleteColumn, renameColumn, updateColumnConstraints, - updateColumnCurrency, + updateColumnMetadata, updateColumnOptions, updateColumnType, } from '@/lib/table' import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' -import { columnTypeById } from '@/lib/table/column-types' -import { isSupportedCurrencyCode } from '@/lib/table/currency' +import { columnTypeById, metadataKeysIn, pickMetadata } from '@/lib/table/column-types' +import { validateMetadataUpdate } from '@/lib/table/columns/metadata' import { signalTableSchemaChanged } from '@/lib/table/events' import { accessError, @@ -179,14 +179,17 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu ) } + // Which type-specific keys this payload carries, and which writer owns + // each. Read from the registry rather than named here, so a new metadata + // key routes correctly without touching this route. + const { generic: genericMetadataKeys, dedicated: dedicatedMetadataKeys } = + metadataKeysIn(updates) + // A retype applies and validates the constraints itself, so the separate // constraint write only runs when the type is unchanged. The rename rides // whichever write actually runs last. const typedWriteRuns = - typeChanging || - updates.currencyCode !== undefined || - updates.options !== undefined || - updates.multiple !== undefined + typeChanging || genericMetadataKeys.length > 0 || dedicatedMetadataKeys.length > 0 const constraintsWriteRuns = !typedWriteRuns && (updates.required !== undefined || updates.unique !== undefined) const renameWithTypedWrite = @@ -199,23 +202,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu // changing: an options-only update on an existing select column carries the // same hazard as a conversion does. const resultingType = updates.type ?? currentColumn?.type - if (updates.currencyCode !== undefined) { - if (resultingType !== 'currency') { - return NextResponse.json( - { - error: `Cannot set currency on column "${validated.columnName}" of type "${resultingType}"`, - }, - { status: 400 } - ) - } - if (!isSupportedCurrencyCode(updates.currencyCode)) { - return NextResponse.json( - { - error: `Invalid currency code "${updates.currencyCode}". Use an ISO 4217 code, e.g. USD`, - }, - { status: 400 } - ) - } + const metadataError = validateMetadataUpdate(currentColumn, resultingType, updates) + if (metadataError) { + return NextResponse.json({ error: metadataError }, { status: 400 }) } // The rename runs last (see below), so a name already taken would fail after // the typed write committed. This is the only rename failure a caller can @@ -258,9 +247,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu tableId, columnName: columnRef, newType: updates.type as NonNullable, - ...(updates.options !== undefined ? { options: updates.options } : {}), - ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), - ...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}), + // Every type-specific key the payload carries, whichever writer would + // own it standalone: a conversion applies its target's metadata in the + // same transaction rather than leaving it to a second write. + ...pickMetadata(updates, [...genericMetadataKeys, ...dedicatedMetadataKeys]), // Forwarded so the conversion validates against the constraint this // same request is about to set, not the column's current one. ...(updates.required !== undefined ? { required: updates.required } : {}), @@ -269,22 +259,23 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu }, requestId ) - } else if (updates.currencyCode !== undefined) { - // Re-denominating an existing currency column: schema-only, no cell - // rewrite. Reached only when the type is unchanged — a conversion INTO - // currency carries the code through `updateColumnType` above. - updatedTable = await updateColumnCurrency( + } else if (genericMetadataKeys.length > 0) { + // Changing a column's own metadata — re-denominating a currency, changing + // a number's precision. Usually schema-only; the type declares a cell + // rewrite if it needs one. Reached only when the type is unchanged, since + // a conversion carries its metadata through `updateColumnType` above. + updatedTable = await updateColumnMetadata( { tableId, columnName: columnRef, - currencyCode: updates.currencyCode, + metadata: pickMetadata(updates, genericMetadataKeys), ...(updates.required !== undefined ? { required: updates.required } : {}), ...(updates.unique !== undefined ? { unique: updates.unique } : {}), ...renameWithTypedWrite, }, requestId ) - } else if (updates.options !== undefined || updates.multiple !== undefined) { + } else if (dedicatedMetadataKeys.length > 0) { updatedTable = await updateColumnOptions( { tableId, 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 dd81326fefc..d120d20d415 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -34,18 +34,24 @@ import { sortSpecNamesToIds, } from '@/lib/table/column-keys' import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' -import { columnTypeById } from '@/lib/table/column-types' +import { + columnTypeById, + metadataKeysIn, + pickMetadata, + TYPE_SPECIFIC_COLUMN_KEYS, + validateTypeMetadata, +} from '@/lib/table/column-types' +import { validateMetadataUpdate } from '@/lib/table/columns/metadata' import { addTableColumn, deleteColumn, deleteColumns, renameColumn, updateColumnConstraints, - updateColumnCurrency, + updateColumnMetadata, updateColumnOptions, updateColumnType, } from '@/lib/table/columns/service' -import { isSupportedCurrencyCode } from '@/lib/table/currency' import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' import { signalTableRowsChanged, signalTableSchemaChanged } from '@/lib/table/events' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' @@ -1569,11 +1575,13 @@ export const userTableServerTool: BaseServerTool } const requestId = generateId().slice(0, 8) assertNotAborted() - if (col.currencyCode !== undefined && !isSupportedCurrencyCode(col.currencyCode)) { - return { - success: false, - message: `Invalid currency code "${col.currencyCode}". Use an ISO 4217 code, e.g. USD`, - } + // The new column's own type-specific metadata, checked by that type's + // `validateDefinition`. `addTableColumn` validates again inside its + // transaction; running it here first is what turns a bad value into a + // helpful agent-facing message rather than a generic write failure. + const metadataErrors = validateTypeMetadata(col as ColumnDefinition) + if (metadataErrors.length > 0) { + return { success: false, message: metadataErrors[0] } } // Agent authors select options by name; generate their stable ids here. const columnToAdd = @@ -1678,24 +1686,22 @@ export const userTableServerTool: BaseServerTool const uniqFlag = (args as Record).unique as boolean | undefined const rawOptions = (args as Record).options const multiple = (args as Record).multiple as boolean | undefined - const currencyCode = (args as Record).currencyCode as string | undefined + // Every type-specific key the agent may have supplied, read off the + // loose arg bag by the registry's key list rather than named here — so + // a new column type's metadata is accepted the moment it is declared. + const rawMetadata: Partial = {} + for (const key of TYPE_SPECIFIC_COLUMN_KEYS) { + const value = (args as Record)[key] + if (value !== undefined) Object.assign(rawMetadata, { [key]: value }) + } if ( newType === undefined && uniqFlag === undefined && - rawOptions === undefined && - multiple === undefined && - currencyCode === undefined + Object.keys(rawMetadata).length === 0 ) { return { success: false, - message: - 'At least one of newType, unique, options, multiple, or currencyCode must be provided', - } - } - if (currencyCode !== undefined && !isSupportedCurrencyCode(currencyCode)) { - return { - success: false, - message: `Invalid currency code "${currencyCode}". Use an ISO 4217 code, e.g. USD`, + message: `At least one of newType, unique, or ${TYPE_SPECIFIC_COLUMN_KEYS.join(', ')} must be provided`, } } const tableForUpdate = await getTableById(args.tableId) @@ -1710,6 +1716,13 @@ export const userTableServerTool: BaseServerTool ) const existingOptions = currentColumn?.options ?? [] const options = normalizeSelectOptionsInput(rawOptions, existingOptions) + // Validate and write the NORMALIZED options, not the agent's raw + // name-only list: `validateDefinition` requires every option to carry + // an id, which is minted just above. + const metadataUpdates: Partial = { + ...rawMetadata, + ...(options !== undefined ? { options } : {}), + } // An agent restating the current type alongside new options must not // go through `updateColumnType` — it early-returns on an unchanged // type and would drop them. Mirrors the HTTP columns route. @@ -1733,6 +1746,21 @@ export const userTableServerTool: BaseServerTool message: `Cannot set column "${colName}" as unique: ${resultingType} columns cannot be unique.`, } } + // Same registry-driven ownership + value check the HTTP routes apply, + // so the agent gets the type's own message ("Use an ISO 4217 code") + // instead of a bespoke one per key. + if (currentColumn) { + const metadataError = validateMetadataUpdate( + currentColumn, + resultingType, + metadataUpdates + ) + if (metadataError) { + return { success: false, message: metadataError } + } + } + const { generic: genericMetadataKeys, dedicated: dedicatedMetadataKeys } = + metadataKeysIn(metadataUpdates) if (typeChanging) { assertNotAborted() result = await updateColumnType( @@ -1742,31 +1770,26 @@ export const userTableServerTool: BaseServerTool newType: newType as (typeof COLUMN_TYPES)[number], options, multiple, - ...(currencyCode !== undefined ? { currencyCode } : {}), + ...pickMetadata(metadataUpdates, genericMetadataKeys), ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), }, requestId ) - } else if (currencyCode !== undefined) { - // Re-denominating an existing currency column: schema-only, no cell - // rewrite. Mirrors the HTTP columns routes. - if (currentColumn?.type !== 'currency') { - return { - success: false, - message: `Column "${colName}" is not a currency column. Pass newType: "currency" with currencyCode to convert it.`, - } - } + } else if (genericMetadataKeys.length > 0) { + // Changing a column's own metadata without a type change — + // schema-only unless the type declares a cell rewrite. Mirrors the + // HTTP columns routes. assertNotAborted() - result = await updateColumnCurrency( + result = await updateColumnMetadata( { tableId: args.tableId, columnName: colName, - currencyCode, + metadata: pickMetadata(metadataUpdates, genericMetadataKeys), ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), }, requestId ) - } else if (options !== undefined || multiple !== undefined) { + } else if (dedicatedMetadataKeys.length > 0) { // Editing an existing select column's option set / mode without a // type change. `multiple` alone is a valid update — the catalog // documents it as independent — so fall back to the column's current diff --git a/apps/sim/lib/table/column-types/registry.server.ts b/apps/sim/lib/table/column-types/registry.server.ts index a87eda3604a..d80f7e82b73 100644 --- a/apps/sim/lib/table/column-types/registry.server.ts +++ b/apps/sim/lib/table/column-types/registry.server.ts @@ -236,3 +236,11 @@ export function migrationTo(type: ColumnType): ColumnCellMigration | undefined { export function migrationFrom(type: ColumnType): ColumnCellMigration | undefined { return COLUMN_TYPE_SERVER_REGISTRY[type]?.migrateCellsFrom } + +/** + * The same-type migration a metadata change runs, if the type declares one. + * Absent for presentational metadata, which must never touch a row. + */ +export function metadataMigrationFor(type: ColumnType): ColumnCellMigration | undefined { + return COLUMN_TYPE_SERVER_REGISTRY[type]?.migrateCellsForMetadata +} diff --git a/apps/sim/lib/table/column-types/registry.ts b/apps/sim/lib/table/column-types/registry.ts index 8bc336a1dc1..f0d6796e5c4 100644 --- a/apps/sim/lib/table/column-types/registry.ts +++ b/apps/sim/lib/table/column-types/registry.ts @@ -30,7 +30,11 @@ import { selectColumnType, } from '@/lib/table/column-types/select' import { stringColumnType } from '@/lib/table/column-types/string' -import type { ColumnType, ColumnTypeDefinition } from '@/lib/table/column-types/types' +import type { + ColumnType, + ColumnTypeDefinition, + TypeSpecificColumnKey, +} from '@/lib/table/column-types/types' import { COLUMN_TYPES, TYPE_SPECIFIC_COLUMN_KEYS } from '@/lib/table/column-types/types' import type { ColumnDefinition, JsonValue } from '@/lib/table/types' @@ -115,3 +119,68 @@ export function typeMetadataOf(column: ColumnDefinition): Partial | null { return columnTypeOf(column).filterOperatorsFor?.(column) ?? null } + +/** + * Metadata key → the type that owns it. Built once from `ownedMetadata`, which + * is already the declaration every type makes; deriving the reverse index here + * is what lets the routes ask "may this column carry this key?" without naming + * a single key themselves. + */ +const METADATA_KEY_OWNER = new Map() +for (const definition of Object.values(COLUMN_TYPE_REGISTRY)) { + for (const key of definition.ownedMetadata) { + METADATA_KEY_OWNER.set(key, definition) + } +} + +/** The column type that owns a type-specific metadata key. */ +export function ownerOfMetadataKey(key: TypeSpecificColumnKey): ColumnTypeDefinition | undefined { + return METADATA_KEY_OWNER.get(key) +} + +/** + * The type-specific metadata keys present in an update payload, split by which + * writer handles them. + * + * `generic` goes to `updateColumnMetadata` (schema write, plus the type's + * `migrateCellsForMetadata` when it declares one); `dedicated` is a key whose + * owner keeps its own writer — today only `select`'s `options` / `multiple`. + * Callers branch on which set is non-empty instead of testing key names. + */ +export function metadataKeysIn(updates: Partial): { + generic: TypeSpecificColumnKey[] + dedicated: TypeSpecificColumnKey[] +} { + const generic: TypeSpecificColumnKey[] = [] + const dedicated: TypeSpecificColumnKey[] = [] + for (const key of TYPE_SPECIFIC_COLUMN_KEYS) { + if (updates[key] === undefined) continue + const owner = METADATA_KEY_OWNER.get(key) + const handled = owner?.genericMetadataUpdate ?? owner?.ownedMetadata ?? [] + ;(handled.includes(key) ? generic : dedicated).push(key) + } + return { generic, dedicated } +} + +/** Whether a column of `type` may carry `key`. */ +export function typeOwnsMetadataKey(type: string | undefined, key: TypeSpecificColumnKey): boolean { + return columnTypeById(type).ownedMetadata.includes(key) +} + +/** + * The named metadata keys from an update payload, as a spreadable object. + * + * Skips keys the payload does not carry, so spreading the result never + * introduces an explicit `undefined` — which would otherwise read as "clear + * this key" to `filterUndefined` further down the write path. + */ +export function pickMetadata( + updates: Partial, + keys: readonly TypeSpecificColumnKey[] +): Partial { + const picked: Partial = {} + for (const key of keys) { + if (updates[key] !== undefined) Object.assign(picked, { [key]: updates[key] }) + } + return picked +} diff --git a/apps/sim/lib/table/column-types/select.ts b/apps/sim/lib/table/column-types/select.ts index 88c3f65ec09..79bca449264 100644 --- a/apps/sim/lib/table/column-types/select.ts +++ b/apps/sim/lib/table/column-types/select.ts @@ -41,6 +41,10 @@ export const selectColumnType: ColumnTypeDefinition = { supportsUnique: false, sampleValue: 'Option', ownedMetadata: ['options', 'multiple'], + // Both keep `updateColumnOptions`: changing them runs an option-removal guard + // and rewrites every cell between option ids and names, which the generic + // schema-only writer deliberately does not do. + genericMetadataUpdate: [], workflowInputType: 'string', editor: 'select', expandable: false, diff --git a/apps/sim/lib/table/column-types/types.server.ts b/apps/sim/lib/table/column-types/types.server.ts index c650c31c72e..349b2f4fb4a 100644 --- a/apps/sim/lib/table/column-types/types.server.ts +++ b/apps/sim/lib/table/column-types/types.server.ts @@ -41,6 +41,18 @@ export interface ColumnTypeServerDefinition { * other type — a `select`'s option ids, for instance. */ readonly migrateCellsFrom?: ColumnCellMigration + /** + * Rewrites cells when this type's own metadata changes on a column that + * keeps its type — `date` re-normalizing between an instant and a calendar + * day as `includeTime` is toggled. + * + * Omitted by types whose metadata is purely presentational: re-denominating + * a `currency` column or changing a `number`'s `precision` reformats what is + * already stored and must NOT touch a row. + * + * `previous` and `target` differ only in the metadata keys being written. + */ + readonly migrateCellsForMetadata?: ColumnCellMigration } /** A column type plus its server-only migrations. */ diff --git a/apps/sim/lib/table/column-types/types.ts b/apps/sim/lib/table/column-types/types.ts index 0de148ac1d0..ccce4aad137 100644 --- a/apps/sim/lib/table/column-types/types.ts +++ b/apps/sim/lib/table/column-types/types.ts @@ -67,6 +67,36 @@ export type TypeSpecificColumnKey = (typeof TYPE_SPECIFIC_COLUMN_KEYS)[number] /** Result of coercing a raw value toward a column's declared type. */ export type CoerceResult = { ok: true; value: JsonValue } | { ok: false } +/** + * How the grid draws a plain (non-workflow-output) cell of this type. + * + * Plain data, deliberately — no React here, so the registry stays importable + * from the server half. The grid owns the actual markup for each kind; the type + * only says *which* kind its value is, which is what lets a type stop being a + * `column.type === …` branch in `cell-render.tsx`. + * + * `linkable` is text the grid may promote to a favicon link or an in-workspace + * resource chip — that promotion needs the current workspace id, which is + * request context the registry has no business holding. + */ +export type ColumnCellDisplay = + /** Plain text, already formatted. */ + | { kind: 'text'; text: string } + /** Text that may be promoted to a link / resource chip if it is wholly a URL. */ + | { kind: 'linkable'; text: string } + /** Monospace one-line JSON. */ + | { kind: 'json'; text: string } + /** Timestamp string, rendered through the grid's date formatter. */ + | { kind: 'date'; text: string } + /** In-place checkbox. */ + | { kind: 'boolean'; checked: boolean } + /** Option pills; the grid resolves ids to options off the column. */ + | { kind: 'select' } + /** Filled/empty stars out of `max`. */ + | { kind: 'rating'; value: number; max: number } + /** Renders nothing. */ + | { kind: 'empty' } + export interface ColumnTypeDefinition { readonly id: ColumnType @@ -119,6 +149,20 @@ export interface ColumnTypeDefinition { */ readonly ownedMetadata: readonly TypeSpecificColumnKey[] + /** + * Which of {@link ownedMetadata} the generic `updateColumnMetadata` writer + * handles. Defaults to all of them. + * + * `select` overrides this to empty: changing its `options` / `multiple` + * needs an option-removal guard and rewrites every cell between ids and + * names, so those keep their dedicated `updateColumnOptions` path. Every + * other key is schema-only (or declares a + * `migrateCellsForMetadata` in the server registry) and needs no bespoke + * writer — which is what stops each new key from adding another near-copy of + * the six that `currencyCode` used to need. + */ + readonly genericMetadataUpdate?: readonly TypeSpecificColumnKey[] + /** Workflow/block param type a column of this type maps onto. */ readonly workflowInputType: 'string' | 'number' | 'boolean' | 'object' diff --git a/apps/sim/lib/table/columns/metadata.ts b/apps/sim/lib/table/columns/metadata.ts new file mode 100644 index 00000000000..742336b3601 --- /dev/null +++ b/apps/sim/lib/table/columns/metadata.ts @@ -0,0 +1,62 @@ +/** + * Boundary checks for a column-metadata update, shared by both column routes + * and the copilot table tool. + * + * Each write below is its own locked transaction, so a request that is going to + * fail deep in the service leaves earlier writes committed. These run before + * any write at all — and they are registry-driven, so a new metadata key is + * rejected on the wrong column type and validated on the right one without an + * edit here. + */ + +import { + columnTypeById, + metadataKeysIn, + ownerOfMetadataKey, + pickMetadata, +} from '@/lib/table/column-types' +import type { ColumnDefinition } from '@/lib/table/types' + +/** + * Rejects a metadata update that the column's resulting type cannot accept. + * + * Two failures, in order: + * + * 1. **Ownership** — a key the resulting type does not own. Gated on the type + * the column ENDS UP with, not on whether the type is changing: setting + * `precision` on a column that is simultaneously converting to `string` is + * the same hazard as setting it on an existing `string` column. + * 2. **Value** — the owning type's own `validateDefinition`, run against the + * column as it would be after the write, so the message is the type's + * ("Use an ISO 4217 code, e.g. USD") rather than a generic one. + * + * @returns An error message, or null when the update is acceptable. + */ +export function validateMetadataUpdate( + currentColumn: ColumnDefinition, + resultingType: string | undefined, + updates: Partial +): string | null { + const { generic, dedicated } = metadataKeysIn(updates) + const definition = columnTypeById(resultingType) + + for (const key of [...generic, ...dedicated]) { + if (definition.ownedMetadata.includes(key)) continue + const owner = ownerOfMetadataKey(key) + return `Cannot set ${key} on column "${currentColumn.name}" of type "${resultingType}"${ + owner ? ` — it applies to ${owner.label} columns` : '' + }` + } + + // Merge only the metadata keys — never the rest of the payload. Spreading + // `updates` wholesale would fold in a pending `name`, so rejecting a bad + // value would name the column by a rename that this very request is refusing + // to perform. + const resulting: ColumnDefinition = { + ...currentColumn, + ...(resultingType ? { type: definition.id } : {}), + ...pickMetadata(updates, [...generic, ...dedicated]), + } + const errors = definition.validateDefinition?.(resulting) ?? [] + return errors[0] ?? null +} diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 2ed1dd3cbda..43f5ac94bce 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -12,7 +12,7 @@ import { db } from '@sim/db' import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { omit } from '@sim/utils/object' +import { filterUndefined, omit } from '@sim/utils/object' import { and, count, eq, sql } from 'drizzle-orm' import { columnMatchesRef, generateColumnId, getColumnId } from '@/lib/table/column-keys' import { @@ -20,14 +20,15 @@ import { columnTypeOf, isValueCompatible, TYPE_SPECIFIC_COLUMN_KEYS, + type TypeSpecificColumnKey, } from '@/lib/table/column-types' import { + metadataMigrationFor, migrationFrom, migrationTo, writeBackCoercedCells, } from '@/lib/table/column-types/registry.server' import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' -import { resolveCurrencyCode } from '@/lib/table/currency' import { assertColumnDestructive, assertSchemaMutable } from '@/lib/table/mutation-locks' import type { DbTransaction } from '@/lib/table/planner' import { stripGroupExecutions } from '@/lib/table/rows/executions' @@ -45,7 +46,7 @@ import type { TableMetadata, TableSchema, UpdateColumnConstraintsData, - UpdateColumnCurrencyData, + UpdateColumnMetadataData, UpdateColumnOptionsData, UpdateColumnTypeData, } from '@/lib/table/types' @@ -1110,20 +1111,30 @@ export async function updateColumnOptions( } /** - * Changes the currency a `currency` column renders in. + * Changes a column's own type-specific metadata without changing its type — a + * `currency`'s ISO code, a `number`'s `precision`, a `date`'s `includeTime`. * - * Deliberately the cheapest column mutation in this module: cells store a bare - * number, so re-denominating a column touches only the schema — no row rewrite, - * no compatibility scan, no scaled timeouts. It notably does **not** convert - * amounts between currencies; `1000` stays `1000`, now labelled in the new code. + * One writer for every such key, driven by the registry. Which keys a column + * may carry comes from its type's `ownedMetadata`, normalization from + * `defaultMetadata`, and validation from `validateDefinition`, so adding a + * metadata key needs no edit here at all. This replaced six near-identical + * copies of `updateColumnCurrency` (service, both column routes, the copilot + * tool) that each named `currencyCode` by hand. * - * @param data - Column + target ISO 4217 code + * Usually the cheapest column mutation in this module: presentational metadata + * reformats what is already stored, so no row is touched and no compatibility + * scan runs. Re-denominating a currency column notably does **not** convert + * amounts — `1000` stays `1000`, now labelled in the new code. A type whose + * metadata does change the stored bytes declares `migrateCellsForMetadata` and + * gets a scaled-timeout cell rewrite inside this same transaction. + * + * @param data - Column + the metadata keys to write * @param requestId - Request ID for logging * @returns Updated table definition - * @throws Error if the table or column is missing, or the column is not a currency column + * @throws Error if the table or column is missing, or the column's type does not own a key being set */ -export async function updateColumnCurrency( - data: UpdateColumnCurrencyData, +export async function updateColumnMetadata( + data: UpdateColumnMetadataData, requestId: string ): Promise { return withLockedTable(data.tableId, async (table, trx) => { @@ -1136,40 +1147,68 @@ export async function updateColumnCurrency( } const column = schema.columns[columnIndex] - if (column.type !== 'currency') { - throw new Error(`Cannot set currency on column "${column.name}" of type "${column.type}"`) + const definition = columnTypeOf(column) + const incoming = filterUndefined(data.metadata) + for (const key of Object.keys(incoming) as TypeSpecificColumnKey[]) { + if (!definition.ownedMetadata.includes(key)) { + throw new Error(`Cannot set ${key} on column "${column.name}" of type "${column.type}"`) + } } - const updatedColumn: ColumnDefinition = { - ...column, - currencyCode: resolveCurrencyCode(data.currencyCode), - } + // Normalize through the type's own defaults so what lands in the schema is + // exactly what a newly created column of this type would carry — an + // omitted currency code resolves to the default rather than persisting as + // undefined and re-resolving on every read. + const merged: ColumnDefinition = { ...column, ...incoming } + const updatedColumn: ColumnDefinition = { ...merged, ...definition.defaultMetadata?.(merged) } + const columnValidation = validateColumnDefinition(updatedColumn) if (!columnValidation.valid) { throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) } - const constrained = await applyConstraints( - trx, - data.tableId, - updatedColumn, - getColumnId(column), - data - ) + const columnKey = getColumnId(column) + const constrained = await applyConstraints(trx, data.tableId, updatedColumn, columnKey, data) - // Only a no-op when nothing at all changed — currency, constraints, name. + // Compare every owned key, not just the ones passed in: `defaultMetadata` + // may have resolved one the caller omitted. + const metadataChanged = definition.ownedMetadata.some( + (key) => updatedColumn[key] !== column[key] + ) const renamePending = data.newName !== undefined && data.newName !== column.name - if ( - constrained === updatedColumn && - updatedColumn.currencyCode === column.currencyCode && - !renamePending - ) { + if (constrained === updatedColumn && !metadataChanged && !renamePending) { return table } - const withCurrency = schema.columns.map((c, i) => (i === columnIndex ? constrained : c)) - const updatedColumns = withCurrency.map((c, i) => - i === columnIndex ? applyPendingRename(withCurrency, columnIndex, data.newName) : c + // Only the types whose metadata changes the stored bytes declare this. It + // runs inside this transaction so a failed rewrite cannot leave the schema + // claiming a shape the cells do not have. + if (metadataChanged) { + const migrate = metadataMigrationFor(column.type) + if (migrate) { + // Same scaling the retype and options writers use — a set-based rewrite + // over a large table must not trip the default 5s statement timeout, + // and `idleMs` has to move with it or the transaction is killed between + // statements instead. + const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { + baseMs: 60_000, + perRowMs: 2, + }) + await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) + await migrate({ + trx, + tableId: data.tableId, + columnKey, + previous: column, + target: updatedColumn, + resolved: new Map(), + }) + } + } + + const withMetadata = schema.columns.map((c, i) => (i === columnIndex ? constrained : c)) + const updatedColumns = withMetadata.map((c, i) => + i === columnIndex ? applyPendingRename(withMetadata, columnIndex, data.newName) : c ) const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } const now = new Date() @@ -1180,7 +1219,7 @@ export async function updateColumnCurrency( .where(eq(userTableDefinitions.id, data.tableId)) logger.info( - `[${requestId}] Set currency for column "${column.name}" to "${updatedColumn.currencyCode}" in table ${data.tableId}` + `[${requestId}] Updated ${Object.keys(incoming).join(', ') || 'metadata'} for column "${column.name}" in table ${data.tableId}` ) return { ...table, schema: updatedSchema, updatedAt: now } diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 40231c77900..dcdbe58f790 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -2,6 +2,7 @@ * Type definitions for user-defined tables. */ +import type { TypeSpecificColumnKey } from '@/lib/table/column-types/types' import type { COLUMN_TYPES, FILTER_OPS } from '@/lib/table/constants' export type ColumnValue = string | number | boolean | null | Date @@ -778,7 +779,17 @@ export interface RenameColumnData { newName: string } -export interface UpdateColumnTypeData { +/** + * Every type-specific metadata key, all optional. `buildConvertedColumn` reads + * `data[key]` for each key in `TYPE_SPECIFIC_COLUMN_KEYS`, so deriving the + * payload's metadata slice from `ColumnDefinition` instead of restating the + * keys is what makes that indexed read a compile error until a newly declared + * key is carriable — rather than a key that silently cannot be set on a + * conversion. + */ +export type ColumnTypeMetadata = Partial> + +export interface UpdateColumnTypeData extends ColumnTypeMetadata { tableId: string columnName: string /** @@ -787,12 +798,6 @@ export interface UpdateColumnTypeData { */ newName?: string newType: (typeof COLUMN_TYPES)[number] - /** Options to set when changing to a `select` type. */ - options?: SelectOption[] - /** Whether the `select` column accepts multiple options per cell. */ - multiple?: boolean - /** Currency to set when changing to the `currency` type. */ - currencyCode?: 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 @@ -827,11 +832,15 @@ export interface UpdateColumnOptionsData { } /** - * Payload for `updateColumnCurrency`. Unlike an options update this rewrites no - * cells — a currency cell stores a plain number, and `currencyCode` only - * changes how it is rendered. + * Payload for `updateColumnMetadata` — writing a column's own type-specific + * keys without changing its type. + * + * Usually rewrites no cells: a currency cell stores a plain number and + * `currencyCode` only changes how it is rendered. A type whose metadata does + * change the stored shape (`date`'s `includeTime`) declares + * `migrateCellsForMetadata` and is rewritten in the same transaction. */ -export interface UpdateColumnCurrencyData { +export interface UpdateColumnMetadataData { tableId: string columnName: string /** @@ -842,7 +851,12 @@ export interface UpdateColumnCurrencyData { /** Constraints to apply in the SAME transaction as this write. */ unique?: boolean required?: boolean - currencyCode: string + /** + * The type-specific keys to write. Every key must be owned by the column's + * type — `ownedMetadata` is the check, so this stays correct as types are + * added without naming a single key here. + */ + metadata: Partial } export interface UpdateColumnConstraintsData { From f27f21613dde2588ed476d9fd60534c35771c57e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 11:38:44 -0700 Subject: [PATCH 02/24] feat(tables): add email, phone, url, percent, duration column types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five types on the existing registry, plus the two metadata keys they need. email / phone / url store text and normalize on write — case-folded addresses (enrichment cascades key on them), E.164-stripped numbers, absolute http(s) URLs. url restricts to http/https because the grid renders it as an anchor, so a javascript: value would otherwise sit behind a link the next viewer clicks. percent and duration store bare numbers so filters and sorts stay numeric: "> 50%" and ">= 1h" are plain numeric ranges rather than string comparisons. percent stores the number as shown (25, not 0.25) so converting to and from `number` rewrites no cells. `precision` (number + percent) replaces `number`'s bare String(value), which rendered a computed 0.30000000000000004 raw. `includeTime` (date) stops a date-only column silently acquiring a time from a paste or import; only an explicit false truncates, so columns predating the key keep their instants while new ones are date-only. Two registry-driven changes fall out. `cell-render.tsx` no longer branches on `column.type` — types declare a `display` kind, which also moves the "renders even when empty" decision (boolean, select) out of an unexplained ordering dependency around a shared isNull early-return. And `import.ts`'s coerceValue, a second write path whose `default` arm String()-ed everything, now falls back to the registry: a text-cast column still keeps the raw string so the row error can name it, but a numeric/timestamptz column nulls instead of storing text that makes every later query on the column error. Metadata ownership is declared once in METADATA_KEY_OWNERS. Each type's ownedMetadata derives from it, and the API contract reads the same map — it is client-reachable and so cannot import the icon-carrying registry. --- .../table-grid/cells/cell-render.tsx | 49 +++++--- apps/sim/lib/api/contracts/tables.ts | 82 ++++++++----- .../__tests__/column-type-registry.test.ts | 30 ++--- apps/sim/lib/table/column-types/boolean.ts | 9 +- apps/sim/lib/table/column-types/currency.ts | 3 +- apps/sim/lib/table/column-types/date.ts | 49 +++++++- apps/sim/lib/table/column-types/duration.ts | 114 ++++++++++++++++++ apps/sim/lib/table/column-types/email.ts | 54 +++++++++ apps/sim/lib/table/column-types/json.ts | 8 +- apps/sim/lib/table/column-types/number.ts | 18 ++- apps/sim/lib/table/column-types/percent.ts | 76 ++++++++++++ apps/sim/lib/table/column-types/phone.ts | 73 +++++++++++ .../lib/table/column-types/registry.server.ts | 44 ++++++- apps/sim/lib/table/column-types/registry.ts | 10 ++ apps/sim/lib/table/column-types/select.ts | 10 +- apps/sim/lib/table/column-types/string.ts | 10 +- apps/sim/lib/table/column-types/types.ts | 59 ++++++++- apps/sim/lib/table/column-types/url.ts | 81 +++++++++++++ apps/sim/lib/table/columns/service.ts | 6 +- apps/sim/lib/table/import.ts | 30 ++++- apps/sim/lib/table/precision.ts | 47 ++++++++ apps/sim/lib/table/types.ts | 20 +++ apps/sim/lib/table/validation.ts | 2 + packages/emcn/src/icons/index.ts | 5 + packages/emcn/src/icons/type-duration.tsx | 26 ++++ packages/emcn/src/icons/type-email.tsx | 26 ++++ packages/emcn/src/icons/type-percent.tsx | 27 +++++ packages/emcn/src/icons/type-phone.tsx | 25 ++++ packages/emcn/src/icons/type-url.tsx | 26 ++++ 29 files changed, 925 insertions(+), 94 deletions(-) create mode 100644 apps/sim/lib/table/column-types/duration.ts create mode 100644 apps/sim/lib/table/column-types/email.ts create mode 100644 apps/sim/lib/table/column-types/percent.ts create mode 100644 apps/sim/lib/table/column-types/phone.ts create mode 100644 apps/sim/lib/table/column-types/url.ts create mode 100644 apps/sim/lib/table/precision.ts create mode 100644 packages/emcn/src/icons/type-duration.tsx create mode 100644 packages/emcn/src/icons/type-email.tsx create mode 100644 packages/emcn/src/icons/type-percent.tsx create mode 100644 packages/emcn/src/icons/type-phone.tsx create mode 100644 packages/emcn/src/icons/type-url.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index 4e16d03912b..dcac9a52dd5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -7,6 +7,7 @@ import { parse } from 'tldts' import { faviconUrl } from '@/lib/core/utils/favicon' import type { RowExecutionMetadata, SelectOption } from '@/lib/table' import { columnTypeOf } from '@/lib/table/column-types' +import type { ColumnCellDisplay } from '@/lib/table/column-types/types' import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils' import { storageToDisplay } from '../../../utils' import { resolveSelectOptions, SelectPill } from '../../select-field' @@ -122,27 +123,37 @@ export function resolveCellRender({ return { kind: 'empty' } } - if (column.type === 'boolean') return { kind: 'boolean', checked: Boolean(value) } - // Always render select cells as the `select` kind — an empty one shows a muted - // "None" so every select cell reads as a clickable dropdown. - if (column.type === 'select') { - return { kind: 'select', options: resolveSelectOptions(column, value) } + // Every plain typed cell: the column's type says WHAT to draw, this switch + // says how. Adding a type therefore adds no branch here — which is the rule + // the previous chain of `column.type === …` tests broke. + const definition = columnTypeOf(column) + const cell: ColumnCellDisplay = definition.display?.(value, column) ?? { + kind: isNull ? 'empty' : 'text', + text: isNull ? '' : definition.formatForDisplay(value, column), } - if (isNull) return { kind: 'empty' } - // Formatted here rather than in a render branch because the symbol and - // fraction digits come from the COLUMN's currency, which the render switch - // (keyed on kind alone) no longer has. Renders as plain text — a currency - // cell is a number cell with a symbol, so it stays left-aligned like one. - if (column.type === 'currency') { - return { kind: 'text', text: columnTypeOf(column).formatForDisplay(value, column) } - } - if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) } - if (column.type === 'date') return { kind: 'date', text: String(value) } - if (column.type === 'string') { - const text = stringifyValue(value) - return resolveLinkKind(text, currentWorkspaceId) ?? { kind: 'text', text } + + switch (cell.kind) { + case 'boolean': + return { kind: 'boolean', checked: cell.checked } + case 'select': + return { kind: 'select', options: resolveSelectOptions(column, value) } + case 'json': + return { kind: 'json', text: cell.text } + case 'date': + return { kind: 'date', text: cell.text } + case 'linkable': + // Promotion needs the current workspace id, which is request context the + // registry deliberately does not hold. + return resolveLinkKind(cell.text, currentWorkspaceId) ?? { kind: 'text', text: cell.text } + case 'empty': + return { kind: 'empty' } + case 'text': + return { kind: 'text', text: cell.text } + default: { + const _exhaustive: never = cell + return _exhaustive + } } - return { kind: 'text', text: stringifyValue(value) } } function stringifyValue(value: unknown): string { diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 075a1a8a199..380b4ea3a2c 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -24,6 +24,7 @@ import type { TableRowsCursor, TableViewConfig, } from '@/lib/table' +import { METADATA_KEY_OWNERS, TYPE_SPECIFIC_COLUMN_KEYS } from '@/lib/table/column-types/types' import { COLUMN_TYPES, FILTER_OPS, @@ -33,6 +34,7 @@ import { TABLE_LIMITS, } from '@/lib/table/constants' import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' +import type { ColumnTypeMetadata } from '@/lib/table/types' export const domainObjectSchema = () => z.custom(isRecordLike) @@ -64,15 +66,29 @@ export const selectOptionsSchema = z * table would make any divergence between the two runtimes' currency lists * reject an entire table schema over one column's code. */ +/** + * Decimal places for a `number` / `percent` column. + * + * Bounded here as well as server-side because this schema parses RESPONSES + * too — an out-of-range value stored by an older client would otherwise reject + * the whole table schema on read. + */ +export const precisionSchema = z + .number() + .int('precision must be a whole number of decimal places') + .min(0, 'precision cannot be negative') + .max(10, 'precision cannot exceed 10 decimal places') + export const currencyCodeSchema = z .string() .regex(/^[A-Za-z]{3}$/, 'Must be a 3-letter ISO 4217 currency code, e.g. USD') .transform((code) => code.toUpperCase()) /** - * 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 + * Cross-field rule: a `select` column must declare a non-empty option set, and + * no column may carry a type-specific key its type does not own. Ownership is + * read from `METADATA_KEY_OWNERS`, the same map the server enforces, so this + * needs no edit as keys are added. Skipped when `type` is absent (a * metadata-only update on an existing column). */ function refineColumnOptions( @@ -80,45 +96,37 @@ function refineColumnOptions( type?: (typeof COLUMN_TYPES)[number] options?: z.infer multiple?: boolean - currencyCode?: string - }, + } & ColumnTypeMetadata, ctx: z.RefinementCtx ): void { - // `currencyCode` on a non-currency column is inert until a later - // convert-to-currency inherits it, silently overriding the currency the user - // picked in that request. - if (data.type !== undefined && data.type !== 'currency' && data.currencyCode !== undefined) { - ctx.addIssue({ - code: 'custom', - path: ['currencyCode'], - message: 'currencyCode is only allowed on currency columns', - }) - } - if (data.type === 'select') { - if (!data.options || data.options.length === 0) { - ctx.addIssue({ - code: 'custom', - path: ['options'], - message: 'A select column must define at least one option', - }) - } - return - } - if (data.type === undefined) return - if (data.options && data.options.length > 0) { + // A select column must actually declare options — checked before the + // ownership sweep so the message is about what is missing, not what is extra. + if (data.type === 'select' && (!data.options || data.options.length === 0)) { ctx.addIssue({ code: 'custom', path: ['options'], - message: 'options are only allowed on select columns', + message: 'A select column must define at least one option', }) } - // `multiple` stored on a non-select column is inert until a later - // convert-to-select inherits it, silently producing a multiselect. - if (data.multiple) { + // Skipped when `type` is absent: a metadata-only update on an existing column + // carries no type to check ownership against, and the server re-checks it + // against the stored one. + if (data.type === undefined) return + + // Every type-specific key, against the registry's ownership map. A key on a + // type that does not own it is inert until a later conversion inherits it and + // silently overrides what that request asked for — `currencyCode` riding onto + // a convert-to-currency is the case this originally guarded, and the sweep + // now covers each new key without an edit here. + for (const key of TYPE_SPECIFIC_COLUMN_KEYS) { + if (data[key] === undefined || data[key] === false) continue + if (key === 'options' && (!data.options || data.options.length === 0)) continue + const owners = METADATA_KEY_OWNERS[key] + if (owners.includes(data.type)) continue ctx.addIssue({ code: 'custom', - path: ['multiple'], - message: 'multiple is only allowed on select columns', + path: [key], + message: `${key} is only allowed on ${owners.join(' / ')} columns`, }) } } @@ -191,6 +199,10 @@ export const tableColumnSchema = z options: selectOptionsSchema.optional(), /** A `select` column that accepts multiple options per cell. */ multiple: z.boolean().optional(), + /** Decimal places for a `number` / `percent` column. */ + precision: precisionSchema.optional(), + /** Whether a `date` column carries a time of day. */ + includeTime: z.boolean().optional(), /** ISO 4217 code for a `currency` column. */ currencyCode: currencyCodeSchema.optional(), }) @@ -270,6 +282,8 @@ export const createTableColumnBodySchema = z.object({ position: z.number().int().min(0).optional(), options: selectOptionsSchema.optional(), multiple: z.boolean().optional(), + precision: precisionSchema.optional(), + includeTime: z.boolean().optional(), currencyCode: currencyCodeSchema.optional(), }) .superRefine(refineColumnOptions), @@ -286,6 +300,8 @@ export const updateTableColumnBodySchema = z.object({ unique: z.boolean().optional(), options: selectOptionsSchema.optional(), multiple: z.boolean().optional(), + precision: precisionSchema.optional(), + includeTime: z.boolean().optional(), currencyCode: currencyCodeSchema.optional(), }) .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 73c5ffc424c..75ffd81075f 100644 --- a/apps/sim/lib/table/__tests__/column-type-registry.test.ts +++ b/apps/sim/lib/table/__tests__/column-type-registry.test.ts @@ -36,9 +36,9 @@ describe('registry shape', () => { it('falls back to string for an unknown type instead of throwing', () => { // A malformed or future schema must render as text, not crash mid-render. - expect(columnTypeById('percent').id).toBe('string') + expect(columnTypeById('geolocation').id).toBe('string') expect(columnTypeById(undefined).id).toBe('string') - expect(isColumnType('percent')).toBe(false) + expect(isColumnType('geolocation')).toBe(false) expect(isColumnType('currency')).toBe(true) }) @@ -173,19 +173,19 @@ 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'} + ${'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: 'geolocation' as ColumnDefinition['type'] })} | ${false} | ${'invalid type'} `( 'rejects $label', ({ diff --git a/apps/sim/lib/table/column-types/boolean.ts b/apps/sim/lib/table/column-types/boolean.ts index e3f5aacc624..d0d6b1d3e49 100644 --- a/apps/sim/lib/table/column-types/boolean.ts +++ b/apps/sim/lib/table/column-types/boolean.ts @@ -1,5 +1,6 @@ import { TypeBoolean } from '@sim/emcn/icons' import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' +import { ownedKeysOf } from '@/lib/table/column-types/types' export const booleanColumnType: ColumnTypeDefinition = { id: 'boolean', @@ -9,7 +10,7 @@ export const booleanColumnType: ColumnTypeDefinition = { storesOpaqueIds: false, supportsUnique: true, sampleValue: true, - ownedMetadata: [], + ownedMetadata: ownedKeysOf('boolean'), workflowInputType: 'boolean', // Toggled in place on click, Enter, and fill — never opens an editor, so it // has no `typeaheadPattern` and the expanded popover skips it entirely. @@ -34,6 +35,12 @@ export const booleanColumnType: ColumnTypeDefinition = { return String(value) }, + // Draws even when the cell is empty: an absent boolean is an unchecked box, + // not a blank, so every boolean cell reads as a clickable toggle. + display(value) { + return { kind: 'boolean', checked: Boolean(value) } + }, + formatForInput(value) { return String(value) }, diff --git a/apps/sim/lib/table/column-types/currency.ts b/apps/sim/lib/table/column-types/currency.ts index 86485a265d3..382a7441095 100644 --- a/apps/sim/lib/table/column-types/currency.ts +++ b/apps/sim/lib/table/column-types/currency.ts @@ -1,5 +1,6 @@ import { TypeCurrency } from '@sim/emcn/icons' import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' +import { ownedKeysOf } from '@/lib/table/column-types/types' import { formatCurrencyDisplay, formatCurrencyForInput, @@ -16,7 +17,7 @@ export const currencyColumnType: ColumnTypeDefinition = { storesOpaqueIds: false, supportsUnique: true, sampleValue: 123, - ownedMetadata: ['currencyCode'], + ownedMetadata: ownedKeysOf('currency'), workflowInputType: 'number', editor: 'text', expandable: false, diff --git a/apps/sim/lib/table/column-types/date.ts b/apps/sim/lib/table/column-types/date.ts index 11b980eeacd..dbb54dabfdc 100644 --- a/apps/sim/lib/table/column-types/date.ts +++ b/apps/sim/lib/table/column-types/date.ts @@ -1,11 +1,34 @@ import { Calendar as CalendarIcon } from '@sim/emcn/icons' import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' +import { ownedKeysOf } from '@/lib/table/column-types/types' import { formatDateCellDisplay, normalizeDateCellValue, storedDateToEditable, } from '@/lib/table/dates' -import type { JsonValue } from '@/lib/table/types' +import type { ColumnDefinition, JsonValue } from '@/lib/table/types' + +/** + * Drops the time of day from a normalized value when the column is date-only. + * + * `normalizeDateCellValue` already returns a bare `YYYY-MM-DD` for input that + * carried no time, so this only bites when a time arrives anyway — a paste, a + * CSV cell, a tool write. Without it a "Due date" column silently accumulates + * instants, and two rows entered the same day stop comparing equal. + * + * A calendar date is a prefix of the wall-instant form, so the truncation is a + * slice rather than a re-parse; going through `Date` would reintroduce exactly + * the timezone conversion this storage shape exists to avoid. + */ +function applyIncludeTime(normalized: string, column: ColumnDefinition): string { + // Only an EXPLICIT `false` truncates. An absent flag means a column created + // before this key existed, and those columns hold instants — defaulting them + // to date-only would silently truncate a stored time on the next write to any + // cell. New columns get `includeTime: false` stamped at creation instead, so + // the good default applies going forward without rewriting history. + if (column.includeTime !== false) return normalized + return normalized.slice(0, 10) +} export const dateColumnType: ColumnTypeDefinition = { id: 'date', @@ -15,24 +38,27 @@ export const dateColumnType: ColumnTypeDefinition = { storesOpaqueIds: false, supportsUnique: true, sampleValue: '2024-01-31', - ownedMetadata: [], + ownedMetadata: ownedKeysOf('date'), workflowInputType: 'string', editor: 'date', expandable: false, typeaheadPattern: /[\d\-/]/, parseErrorMessage: 'Invalid date', - coerce(value) { + coerce(value, column) { if (typeof value === 'string') { const normalized = normalizeDateCellValue(value) - return normalized === null ? { ok: false } : { ok: true, value: normalized } + if (normalized === null) return { ok: false } + return { ok: true, value: applyIncludeTime(normalized, column) } } // Date instances and epoch numbers may still be out of the representable // range (>±8.64e15ms) — guard `toISOString()`, which throws RangeError on // an Invalid Date, so an over-range value degrades to `{ ok: false }` // rather than crashing the write. const date = value instanceof Date ? value : typeof value === 'number' ? new Date(value) : null - if (date && !Number.isNaN(date.getTime())) return { ok: true, value: date.toISOString() } + if (date && !Number.isNaN(date.getTime())) { + return { ok: true, value: applyIncludeTime(date.toISOString(), column) } + } return { ok: false } }, @@ -53,6 +79,11 @@ export const dateColumnType: ColumnTypeDefinition = { return valid ? null : `${column.name} must be valid date` }, + display(value) { + if (value === null || value === undefined) return { kind: 'empty' } + return { kind: 'date', text: String(value) } + }, + formatForDisplay(value) { return formatDateCellDisplay(String(value), { seconds: true }) }, @@ -60,4 +91,12 @@ export const dateColumnType: ColumnTypeDefinition = { formatForInput(value) { return storedDateToEditable(String(value)) }, + + // Stamped only on creation, so a NEW date column is date-only by default — + // the right shape for the due dates and birthdays most date columns hold — + // while a column that predates the key keeps its instants (see + // `applyIncludeTime`). + defaultMetadata(column) { + return { includeTime: column.includeTime ?? false } + }, } diff --git a/apps/sim/lib/table/column-types/duration.ts b/apps/sim/lib/table/column-types/duration.ts new file mode 100644 index 00000000000..76407b73808 --- /dev/null +++ b/apps/sim/lib/table/column-types/duration.ts @@ -0,0 +1,114 @@ +import { TypeDuration } from '@sim/emcn/icons' +import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' +import { ownedKeysOf } from '@/lib/table/column-types/types' + +/** Guard against `Infinity` and absurd values that would format unreadably. */ +const MAX_SECONDS = 100 * 365 * 24 * 60 * 60 + +/** `1h 30m`, `90m`, `45s`, `2h15m` — the unit-suffixed shorthand. */ +const UNIT_PATTERN = /^(?:(\d+(?:\.\d+)?)h)?\s*(?:(\d+(?:\.\d+)?)m)?\s*(?:(\d+(?:\.\d+)?)s)?$/i + +/** + * Parses a duration into seconds, or null when it is not one. + * + * Accepts the three shapes a duration is written in: a bare number of seconds, + * clock notation (`h:mm:ss` or `mm:ss`), and unit shorthand (`1h 30m`). All + * three arrive in practice — a CSV exports seconds, a person types clock + * notation, an LLM writes shorthand — and rejecting any of them would null the + * cell on write. + */ +function parseDuration(value: unknown): number | null { + if (typeof value === 'number') { + return Number.isFinite(value) && value >= 0 && value <= MAX_SECONDS ? value : null + } + if (typeof value !== 'string') return null + const trimmed = value.trim() + if (trimmed === '') return null + + if (trimmed.includes(':')) { + const parts = trimmed.split(':') + if (parts.length > 3) return null + const nums = parts.map((p) => (p.trim() === '' ? Number.NaN : Number(p))) + if (nums.some((n) => !Number.isFinite(n) || n < 0)) return null + // Right-aligned, so `mm:ss` and `h:mm:ss` share one reduction: seconds are + // always last. Only the leading field may exceed its base — `90:00` is 90 + // minutes, which is exactly how people write it. + const seconds = nums.reduce((total, n) => total * 60 + n, 0) + if (nums.slice(1).some((n) => n >= 60)) return null + return seconds <= MAX_SECONDS ? seconds : null + } + + const bare = Number(trimmed) + if (Number.isFinite(bare)) { + return bare >= 0 && bare <= MAX_SECONDS ? bare : null + } + + const match = UNIT_PATTERN.exec(trimmed) + if (!match || (match[1] === undefined && match[2] === undefined && match[3] === undefined)) { + return null + } + const seconds = Number(match[1] ?? 0) * 3600 + Number(match[2] ?? 0) * 60 + Number(match[3] ?? 0) + return seconds <= MAX_SECONDS ? seconds : null +} + +/** Seconds → `h:mm:ss`, dropping the hours field when there are none. */ +function formatDuration(totalSeconds: number): string { + const rounded = Math.round(totalSeconds) + const hours = Math.floor(rounded / 3600) + const minutes = Math.floor((rounded % 3600) / 60) + const seconds = rounded % 60 + const mm = String(minutes).padStart(2, '0') + const ss = String(seconds).padStart(2, '0') + return hours > 0 ? `${hours}:${mm}:${ss}` : `${minutes}:${ss}` +} + +export const durationColumnType: ColumnTypeDefinition = { + id: 'duration', + label: 'Duration', + icon: TypeDuration, + // Stored as a bare number of seconds, so filters and sorts compare + // numerically and `>= 1h` is a plain numeric range — which is the whole + // reason this is not just formatted text. + jsonbCast: 'numeric', + storesOpaqueIds: false, + supportsUnique: true, + sampleValue: 5400, + ownedMetadata: ownedKeysOf('duration'), + workflowInputType: 'number', + editor: 'text', + expandable: false, + inputMode: 'decimal', + acceptsFormattedInput: true, + typeaheadPattern: /[\d:.hms\s]/i, + parseErrorMessage: 'Invalid duration', + + coerce(value) { + const parsed = parseDuration(value) + return parsed === null ? { ok: false } : { ok: true, value: parsed } + }, + + isCompatibleWith(value) { + // Stricter than `coerce` on the same grounds `date` is. Reading a whole + // NUMBER column as seconds is a guess about what those numbers meant, and + // it is not reversible once the column renders as `0:05`. A single write + // still accepts a number, where the caller means seconds explicitly. + if (typeof value === 'number') return false + return parseDuration(value) !== null + }, + + validateCell(value, column) { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? null + : `${column.name} must be a duration in seconds` + }, + + formatForDisplay(value) { + return typeof value === 'number' ? formatDuration(value) : '' + }, + + // Edited in the same clock notation it displays, so a cell round-trips + // through the editor unchanged. + formatForInput(value) { + return typeof value === 'number' ? formatDuration(value) : '' + }, +} diff --git a/apps/sim/lib/table/column-types/email.ts b/apps/sim/lib/table/column-types/email.ts new file mode 100644 index 00000000000..2261f528046 --- /dev/null +++ b/apps/sim/lib/table/column-types/email.ts @@ -0,0 +1,54 @@ +import { TypeEmail } from '@sim/emcn/icons' +import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' +import { ownedKeysOf } from '@/lib/table/column-types/types' + +/** + * Deliberately permissive, and deliberately not RFC 5322. + * + * A column type's `coerce` is the write path: rejecting means the cell is + * nulled, so an address this cannot parse is data the user loses on paste or + * CSV import. The only shapes worth refusing are the ones that are certainly + * not addresses — no `@`, whitespace inside, a missing local part or domain, or + * a dotless domain. Everything else is stored as typed. + */ +const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/ + +export const emailColumnType: ColumnTypeDefinition = { + id: 'email', + label: 'Email', + icon: TypeEmail, + // Stored as plain text; comparison and sorting are lexical, like `string`. + jsonbCast: null, + storesOpaqueIds: false, + supportsUnique: true, + sampleValue: 'person@example.com', + ownedMetadata: ownedKeysOf('email'), + workflowInputType: 'string', + editor: 'text', + expandable: false, + parseErrorMessage: 'Invalid email address', + + coerce(value) { + if (typeof value !== 'string') return { ok: false } + // Addresses arrive padded from spreadsheets and CSVs, and case-folded is + // the form every downstream match wants — enrichment cascades key on this + // column, and `Ada@Example.com` must not miss `ada@example.com`. + const normalized = value.trim().toLowerCase() + if (normalized === '') return { ok: true, value: '' } + return EMAIL_PATTERN.test(normalized) ? { ok: true, value: normalized } : { ok: false } + }, + + validateCell(value, column) { + if (typeof value !== 'string') return `${column.name} must be an email address` + if (value === '') return null + return EMAIL_PATTERN.test(value) ? null : `${column.name} must be a valid email address` + }, + + formatForDisplay(value) { + return typeof value === 'string' ? value : '' + }, + + formatForInput(value) { + return typeof value === 'string' ? value : '' + }, +} diff --git a/apps/sim/lib/table/column-types/json.ts b/apps/sim/lib/table/column-types/json.ts index 027f57cea64..d5b1f406822 100644 --- a/apps/sim/lib/table/column-types/json.ts +++ b/apps/sim/lib/table/column-types/json.ts @@ -1,5 +1,6 @@ import { TypeJson } from '@sim/emcn/icons' import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' +import { ownedKeysOf } from '@/lib/table/column-types/types' export const jsonColumnType: ColumnTypeDefinition = { id: 'json', @@ -9,7 +10,7 @@ export const jsonColumnType: ColumnTypeDefinition = { storesOpaqueIds: false, supportsUnique: true, sampleValue: 'value', - ownedMetadata: [], + ownedMetadata: ownedKeysOf('json'), workflowInputType: 'object', editor: 'text', expandable: true, @@ -28,6 +29,11 @@ export const jsonColumnType: ColumnTypeDefinition = { } }, + display(value) { + if (value === null || value === undefined) return { kind: 'empty' } + return { kind: 'json', text: JSON.stringify(value) } + }, + formatForDisplay(value) { return JSON.stringify(value) }, diff --git a/apps/sim/lib/table/column-types/number.ts b/apps/sim/lib/table/column-types/number.ts index 92c5e321ba7..53382ee20b9 100644 --- a/apps/sim/lib/table/column-types/number.ts +++ b/apps/sim/lib/table/column-types/number.ts @@ -1,5 +1,7 @@ import { TypeNumber } from '@sim/emcn/icons' import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' +import { ownedKeysOf } from '@/lib/table/column-types/types' +import { clampPrecision, DEFAULT_PRECISION, formatWithPrecision } from '@/lib/table/precision' export const numberColumnType: ColumnTypeDefinition = { id: 'number', @@ -9,7 +11,7 @@ export const numberColumnType: ColumnTypeDefinition = { storesOpaqueIds: false, supportsUnique: true, sampleValue: 123, - ownedMetadata: [], + ownedMetadata: ownedKeysOf('number'), workflowInputType: 'number', editor: 'text', expandable: false, @@ -34,8 +36,18 @@ export const numberColumnType: ColumnTypeDefinition = { : `${column.name} must be number` }, - formatForDisplay(value) { - return String(value) + validateDefinition(column) { + if (column.precision === undefined) return [] + return clampPrecision(column.precision) === column.precision + ? [] + : [ + `Column "${column.name}" has invalid precision ${column.precision}. Use a whole number of decimal places between 0 and ${DEFAULT_PRECISION.max}`, + ] + }, + + formatForDisplay(value, column) { + if (typeof value !== 'number') return String(value) + return formatWithPrecision(value, column.precision) }, formatForInput(value) { diff --git a/apps/sim/lib/table/column-types/percent.ts b/apps/sim/lib/table/column-types/percent.ts new file mode 100644 index 00000000000..f81fd6dc3c5 --- /dev/null +++ b/apps/sim/lib/table/column-types/percent.ts @@ -0,0 +1,76 @@ +import { TypePercent } from '@sim/emcn/icons' +import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' +import { ownedKeysOf } from '@/lib/table/column-types/types' +import { clampPrecision, DEFAULT_PRECISION, formatWithPrecision } from '@/lib/table/precision' + +/** + * Parses a percent input into its stored number. + * + * Stores the number as shown, NOT a 0–1 fraction: a cell reading `25%` holds + * `25`. That keeps the stored value the same one a `number` column would hold, + * so converting between `number` and `percent` rewrites nothing and a filter + * written against either column means the same thing. + */ +function parsePercent(value: unknown): number | null { + if (typeof value === 'number') return Number.isFinite(value) ? value : null + if (typeof value !== 'string') return null + const trimmed = value.trim().replace(/%$/, '').trim() + if (trimmed === '') return null + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : null +} + +export const percentColumnType: ColumnTypeDefinition = { + id: 'percent', + label: 'Percent', + icon: TypePercent, + jsonbCast: 'numeric', + storesOpaqueIds: false, + supportsUnique: true, + sampleValue: 25, + ownedMetadata: ownedKeysOf('percent'), + workflowInputType: 'number', + editor: 'text', + expandable: false, + inputMode: 'decimal', + // Accepts the trailing `%` a user types first, which an `` + // would reject outright. + acceptsFormattedInput: true, + typeaheadPattern: /[\d.\-%]/, + parseErrorMessage: 'Invalid percentage', + + coerce(value) { + const parsed = parsePercent(value) + return parsed === null ? { ok: false } : { ok: true, value: parsed } + }, + + validateCell(value, column) { + return typeof value === 'number' && !Number.isNaN(value) + ? null + : `${column.name} must be number` + }, + + validateDefinition(column) { + if (column.precision === undefined) return [] + return clampPrecision(column.precision) === column.precision + ? [] + : [ + `Column "${column.name}" has invalid precision ${column.precision}. Use a whole number of decimal places between 0 and ${DEFAULT_PRECISION.max}`, + ] + }, + + formatForDisplay(value, column) { + if (typeof value !== 'number') return '' + return `${formatWithPrecision(value, column.precision)}%` + }, + + // The `%` is chrome, not data — an editor pre-filled with it would make the + // user delete it before typing. + formatForInput(value) { + return typeof value === 'number' ? String(value) : '' + }, + + defaultMetadata(column) { + return { precision: clampPrecision(column.precision) } + }, +} diff --git a/apps/sim/lib/table/column-types/phone.ts b/apps/sim/lib/table/column-types/phone.ts new file mode 100644 index 00000000000..39a17be2a76 --- /dev/null +++ b/apps/sim/lib/table/column-types/phone.ts @@ -0,0 +1,73 @@ +import { TypePhone } from '@sim/emcn/icons' +import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' +import { ownedKeysOf } from '@/lib/table/column-types/types' + +/** E.164 allows 1–15 digits; require 7 so a stray year or zip is not a number. */ +const MIN_DIGITS = 7 +const MAX_DIGITS = 15 + +/** + * Strips the punctuation a phone number is written with, keeping a leading `+`. + * + * Returns null when what is left cannot be a phone number. Extensions are + * deliberately not parsed — `x123` has no E.164 representation, so a value + * carrying one is refused rather than silently truncated to the wrong number. + */ +function normalizePhone(raw: string): string | null { + const trimmed = raw.trim() + if (trimmed === '') return '' + const hasPlus = trimmed.startsWith('+') + const rest = hasPlus ? trimmed.slice(1) : trimmed + // Anything that is not a digit or a conventional separator means this is not + // a bare phone number — an extension, a second number, a note. + if (!/^[\d\s\-().]*$/.test(rest)) return null + const digits = rest.replace(/\D/g, '') + if (digits.length < MIN_DIGITS || digits.length > MAX_DIGITS) return null + return `${hasPlus ? '+' : ''}${digits}` +} + +export const phoneColumnType: ColumnTypeDefinition = { + id: 'phone', + label: 'Phone', + icon: TypePhone, + // Stored as text: a phone number is an identifier, not a quantity. Casting to + // numeric would drop the leading `+` and any leading zero. + jsonbCast: null, + storesOpaqueIds: false, + supportsUnique: true, + sampleValue: '+15551234567', + ownedMetadata: ownedKeysOf('phone'), + workflowInputType: 'string', + editor: 'text', + expandable: false, + typeaheadPattern: /[\d+\s\-().]/, + parseErrorMessage: 'Invalid phone number', + + coerce(value) { + // A number reaches here from a CSV whose phone column was read as numeric. + // `String(value)` is right for an integer; a float means it was never a + // phone number, and exponent notation would normalize into nonsense. + if (typeof value === 'number') { + if (!Number.isInteger(value)) return { ok: false } + const normalized = normalizePhone(String(value)) + return normalized === null ? { ok: false } : { ok: true, value: normalized } + } + if (typeof value !== 'string') return { ok: false } + const normalized = normalizePhone(value) + return normalized === null ? { ok: false } : { ok: true, value: normalized } + }, + + validateCell(value, column) { + if (typeof value !== 'string') return `${column.name} must be a phone number` + if (value === '') return null + return normalizePhone(value) === null ? `${column.name} must be a valid phone number` : null + }, + + formatForDisplay(value) { + return typeof value === 'string' ? value : '' + }, + + formatForInput(value) { + return typeof value === '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 d80f7e82b73..fca8f4bbb28 100644 --- a/apps/sim/lib/table/column-types/registry.server.ts +++ b/apps/sim/lib/table/column-types/registry.server.ts @@ -206,6 +206,34 @@ async function migrateCellsToSelectIds( ) } +/** + * Truncates a date column's stored cells to bare calendar days. + * + * Runs when `includeTime` is switched **off**. Without it the schema would + * claim date-only while cells still carried a time, so two rows on the same day + * would keep comparing unequal and the column would render times it says it + * does not have. + * + * A left-slice, not a cast: the stored form is a literal wall time with no + * zone, and `::timestamptz` would resolve it against the server's zone and + * shift the day. Switching `includeTime` back on is deliberately NOT reversible + * — the time of day is gone once truncated, which is why the UI warns first. + */ +async function truncateDateCellsToCalendarDay( + trx: DbTransaction, + tableId: string, + columnKey: string +): Promise { + await trx.execute( + sql`UPDATE ${userTableRows} + SET data = jsonb_set(data, ARRAY[${columnKey}::text], + to_jsonb(left(data->>${columnKey}::text, 10))) + WHERE table_id = ${tableId} + AND jsonb_typeof(data->${columnKey}::text) = 'string' + AND length(data->>${columnKey}::text) > 10` + ) +} + /** * Every column type plus its migrations. The `Record` * annotation is the same completeness gate the client-safe registry uses: a @@ -215,7 +243,16 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record + target.includeTime + ? Promise.resolve() + : truncateDateCellsToCalendarDay(trx, tableId, columnKey), + }, json: COLUMN_TYPE_REGISTRY.json, select: { ...COLUMN_TYPE_REGISTRY.select, @@ -225,6 +262,11 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record = { json: jsonColumnType, select: selectColumnType, currency: currencyColumnType, + percent: percentColumnType, + email: emailColumnType, + phone: phoneColumnType, + url: urlColumnType, + duration: durationColumnType, } /** Every definition, in the same order as {@link COLUMN_TYPES}. */ diff --git a/apps/sim/lib/table/column-types/select.ts b/apps/sim/lib/table/column-types/select.ts index 79bca449264..0e460995e13 100644 --- a/apps/sim/lib/table/column-types/select.ts +++ b/apps/sim/lib/table/column-types/select.ts @@ -1,5 +1,6 @@ import { TagIcon } from '@sim/emcn/icons' import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' +import { ownedKeysOf } from '@/lib/table/column-types/types' import { MAX_SELECT_OPTIONS } from '@/lib/table/constants' import { optionIds, @@ -40,7 +41,7 @@ export const selectColumnType: ColumnTypeDefinition = { storesOpaqueIds: true, supportsUnique: false, sampleValue: 'Option', - ownedMetadata: ['options', 'multiple'], + ownedMetadata: ownedKeysOf('select'), // Both keep `updateColumnOptions`: changing them runs an option-removal guard // and rewrites every cell between option ids and names, which the generic // schema-only writer deliberately does not do. @@ -144,4 +145,11 @@ export const selectColumnType: ColumnTypeDefinition = { formatForInput(value, column) { return selectColumnType.formatForDisplay(value, column) }, + + // Draws even when the cell is empty — an unset select shows a muted "None" + // so it still reads as a dropdown. The grid resolves ids to options off the + // column, which is why no value rides along here. + display() { + return { kind: 'select' } + }, } diff --git a/apps/sim/lib/table/column-types/string.ts b/apps/sim/lib/table/column-types/string.ts index 4dc531c7b34..c1381aa0964 100644 --- a/apps/sim/lib/table/column-types/string.ts +++ b/apps/sim/lib/table/column-types/string.ts @@ -1,5 +1,6 @@ import { TypeText } from '@sim/emcn/icons' import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' +import { ownedKeysOf } from '@/lib/table/column-types/types' export const stringColumnType: ColumnTypeDefinition = { id: 'string', @@ -9,7 +10,7 @@ export const stringColumnType: ColumnTypeDefinition = { storesOpaqueIds: false, supportsUnique: true, sampleValue: 'example', - ownedMetadata: [], + ownedMetadata: ownedKeysOf('string'), workflowInputType: 'string', editor: 'text', expandable: true, @@ -26,6 +27,13 @@ export const stringColumnType: ColumnTypeDefinition = { return typeof value === 'string' ? null : `${column.name} must be string, got ${typeof value}` }, + // Linkable: a string cell holding nothing but a URL is promoted by the grid + // to a favicon link or an in-workspace resource chip. + display(value, column) { + if (value === null || value === undefined) return { kind: 'empty' } + return { kind: 'linkable', text: stringColumnType.formatForDisplay(value, column) } + }, + formatForDisplay(value) { if (typeof value === 'string') return value if (value === null || value === undefined) return '' diff --git a/apps/sim/lib/table/column-types/types.ts b/apps/sim/lib/table/column-types/types.ts index ccce4aad137..d3dbf3f8a96 100644 --- a/apps/sim/lib/table/column-types/types.ts +++ b/apps/sim/lib/table/column-types/types.ts @@ -34,10 +34,15 @@ export const COLUMN_TYPES = [ 'string', 'number', 'currency', + 'percent', 'boolean', 'date', - 'json', 'select', + 'email', + 'phone', + 'url', + 'duration', + 'json', ] as const export type ColumnType = (typeof COLUMN_TYPES)[number] @@ -60,10 +65,43 @@ 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', + 'precision', + 'includeTime', +] as const export type TypeSpecificColumnKey = (typeof TYPE_SPECIFIC_COLUMN_KEYS)[number] +/** + * Which column types may carry each type-specific key — the single declaration + * of metadata ownership. + * + * Lives here rather than on the definitions because this module imports no + * icons, so the API contracts (which are client-reachable and must not pull + * `@sim/emcn/icons`) can enforce the same rule the server does. Each type's + * `ownedMetadata` is derived from this via {@link ownedKeysOf}, so the two + * cannot drift. + * + * A key may have several owners: `number` and `percent` both format to a + * declared number of decimal places, and sharing `precision` is what lets a + * column convert between them without the key being stripped in transit. + */ +export const METADATA_KEY_OWNERS: Record = { + options: ['select'], + multiple: ['select'], + currencyCode: ['currency'], + precision: ['number', 'percent'], + includeTime: ['date'], +} + +/** The type-specific keys a column type owns, for its `ownedMetadata`. */ +export function ownedKeysOf(type: ColumnType): readonly TypeSpecificColumnKey[] { + return TYPE_SPECIFIC_COLUMN_KEYS.filter((key) => METADATA_KEY_OWNERS[key].includes(type)) +} + /** Result of coercing a raw value toward a column's declared type. */ export type CoerceResult = { ok: true; value: JsonValue } | { ok: false } @@ -92,8 +130,6 @@ export type ColumnCellDisplay = | { kind: 'boolean'; checked: boolean } /** Option pills; the grid resolves ids to options off the column. */ | { kind: 'select' } - /** Filled/empty stars out of `max`. */ - | { kind: 'rating'; value: number; max: number } /** Renders nothing. */ | { kind: 'empty' } @@ -231,6 +267,21 @@ export interface ColumnTypeDefinition { /** Stored value → display text (grid cell, CSV, clipboard, width measurement). */ formatForDisplay(value: unknown, column: ColumnDefinition): string + /** + * Stored value → the render kind the grid draws for a plain cell. + * + * Defaults to `formatForDisplay` as plain text, with a null/undefined cell + * rendering as `empty`. Override only when the type draws as something other + * than text — a checkbox, option pills, stars. + * + * A type that renders even when its cell is empty (`boolean` draws an + * unchecked box, `select` draws a muted "None") must say so here, because + * this hook owns the null decision. That used to be an ordering dependency + * in `cell-render.tsx`: the two `column.type ===` checks that had to sit + * above the shared `isNull` early-return, where nothing recorded why. + */ + display?(value: unknown, column: ColumnDefinition): ColumnCellDisplay + /** Stored value → the text an editor input starts with. */ formatForInput(value: unknown, column: ColumnDefinition): string diff --git a/apps/sim/lib/table/column-types/url.ts b/apps/sim/lib/table/column-types/url.ts new file mode 100644 index 00000000000..e1644248c81 --- /dev/null +++ b/apps/sim/lib/table/column-types/url.ts @@ -0,0 +1,81 @@ +import { TypeUrl } from '@sim/emcn/icons' +import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' +import { ownedKeysOf } from '@/lib/table/column-types/types' + +/** + * A bare hostname, the form people paste most (`sim.ai`, `docs.sim.ai`). + * Mirrors the grid's own bare-domain promotion so a value that already renders + * as a link in a text column stays acceptable once the column is typed. + */ +const BARE_DOMAIN = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(\/\S*)?$/ + +/** + * Normalizes to an absolute `http(s)` URL, or null when the value is not one. + * + * Only `http`/`https` are accepted: the grid renders this type as a clickable + * anchor, so admitting `javascript:` or `data:` here would put a user-authored + * scheme behind a link the next viewer clicks. + */ +function normalizeUrl(raw: string): string | null { + const trimmed = raw.trim() + if (trimmed === '') return '' + if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed)) { + try { + const url = new URL(trimmed) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + return url.toString() + } catch { + return null + } + } + if (!BARE_DOMAIN.test(trimmed)) return null + try { + return new URL(`https://${trimmed}`).toString() + } catch { + return null + } +} + +export const urlColumnType: ColumnTypeDefinition = { + id: 'url', + label: 'URL', + icon: TypeUrl, + jsonbCast: null, + storesOpaqueIds: false, + supportsUnique: true, + sampleValue: 'https://sim.ai', + ownedMetadata: ownedKeysOf('url'), + workflowInputType: 'string', + editor: 'text', + // A URL can be far longer than a cell is wide, so double-click opens the + // expanded popover rather than a one-line inline editor. + expandable: true, + parseErrorMessage: 'Invalid URL', + + coerce(value) { + if (typeof value !== 'string') return { ok: false } + const normalized = normalizeUrl(value) + return normalized === null ? { ok: false } : { ok: true, value: normalized } + }, + + validateCell(value, column) { + if (typeof value !== 'string') return `${column.name} must be a URL` + if (value === '') return null + return normalizeUrl(value) === null ? `${column.name} must be a valid http(s) URL` : null + }, + + formatForDisplay(value) { + return typeof value === 'string' ? value : '' + }, + + formatForInput(value) { + return typeof value === 'string' ? value : '' + }, + + // Always linkable: the grid promotes the cell to a favicon link, or to an + // in-workspace resource chip when the URL points back into Sim. + display(value) { + if (value === null || value === undefined || value === '') return { kind: 'empty' } + return { kind: 'linkable', text: String(value) } + }, +} diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 43f5ac94bce..42e25a71a7f 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -37,6 +37,7 @@ import { withLockedTable } from '@/lib/table/service' import { scaledStatementTimeoutMs, setTableTxTimeouts } from '@/lib/table/tx' import type { ColumnDefinition, + ColumnTypeMetadata, DeleteColumnData, JsonValue, RenameColumnData, @@ -66,16 +67,13 @@ const logger = createLogger('TableColumnService') */ export async function addTableColumn( tableId: string, - column: { + column: ColumnTypeMetadata & { id?: string name: string type: string required?: boolean unique?: boolean position?: number - options?: SelectOption[] - multiple?: boolean - currencyCode?: string }, requestId: string ): Promise { diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 02c8a6e431a..537de54336f 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -13,10 +13,10 @@ import { type Options as CsvParseOptions, type Parser, parse as parseCsvStream } from 'csv-parse' import { getColumnId } from '@/lib/table/column-keys' -import type { ColumnType } from '@/lib/table/column-types' +import { type ColumnType, columnTypeById } from '@/lib/table/column-types' import { parseCurrencyInput } from '@/lib/table/currency' import { type NormalizeDateCellOptions, normalizeDateCellValue } from '@/lib/table/dates' -import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types' +import type { ColumnDefinition, JsonValue, RowData, TableSchema } from '@/lib/table/types' /** * Field separators we sniff for, in tie-break priority order. Semicolon files are @@ -385,7 +385,12 @@ export function inferSchemaFromCsv( export function coerceValue( value: unknown, colType: CsvColumnType, - options?: NormalizeDateCellOptions & { currencyCode?: string } + options?: NormalizeDateCellOptions & { + currencyCode?: string + /** The target column, so a type whose coercion depends on its own metadata + * (a `percent`'s precision) reads the column's value rather than the default. */ + column?: Partial + } ): string | number | boolean | null | Record | unknown[] { if (value === null || value === undefined || value === '') return null switch (colType) { @@ -416,8 +421,22 @@ export function coerceValue( return String(value) } } - default: - return String(value) + default: { + // Every other type, driven by the registry rather than a case per id. + // + // The distinction that matters is `jsonbCast`. A text-cast column keeps + // the raw string when its type cannot parse the value — that is this + // function's deliberate difference from the registry's `coerce`, and it + // is what lets the row error name the offending input. A column whose + // cast is `numeric` or `timestamptz` cannot: filters and sorts apply that + // cast to whatever is stored, so a single unparseable cell makes EVERY + // query against the column error in Postgres. Those null instead. + const definition = columnTypeById(colType) + const column: ColumnDefinition = { ...options?.column, name: '', type: definition.id } + const coerced = definition.coerce(value as JsonValue, column) + if (coerced.ok) return coerced.value as ReturnType + return definition.jsonbCast === null ? String(value) : null + } } } @@ -594,6 +613,7 @@ export function coerceRowsForTable( const colType = (col.type as CsvColumnType) ?? 'string' coerced[getColumnId(col)] = coerceValue(value, colType, { ...options, + column: col, ...(col.currencyCode !== undefined ? { currencyCode: col.currencyCode } : {}), }) as RowData[string] } diff --git a/apps/sim/lib/table/precision.ts b/apps/sim/lib/table/precision.ts new file mode 100644 index 00000000000..63de924b680 --- /dev/null +++ b/apps/sim/lib/table/precision.ts @@ -0,0 +1,47 @@ +/** + * Decimal-place formatting shared by the `number` and `percent` column types. + * + * Kept out of both type files because they own the same `precision` key and + * must round it identically — a value that renders as `1.5` in a number column + * and `1.50%` after a conversion would look like the conversion changed it. + * + * Deliberately free of registry imports: `column-types/number.ts` imports this, + * so importing the registry back would close a module-init cycle (the same trap + * `select-values.ts` documents). + */ + +/** Bounds on `precision`, and the value used when a column declares none. */ +export const DEFAULT_PRECISION = { + /** Whole numbers — matches the bare `String(value)` this replaced. */ + value: 0, + min: 0, + /** Beyond this, IEEE-754 doubles have no digits left to show. */ + max: 10, +} as const + +/** + * Rounds `precision` into the supported range, falling back to the default for + * anything that is not a whole number. Used both to normalize what gets stored + * (`defaultMetadata`) and to decide whether a supplied value was valid + * (`validateDefinition`), so the two can never disagree. + */ +export function clampPrecision(precision: number | undefined): number { + if (typeof precision !== 'number' || !Number.isInteger(precision)) { + return DEFAULT_PRECISION.value + } + return Math.min(DEFAULT_PRECISION.max, Math.max(DEFAULT_PRECISION.min, precision)) +} + +/** + * Formats a number to a column's declared decimal places. + * + * A column with no `precision` renders the number as-is rather than forcing + * zero decimals — existing number columns predate this key, and rounding their + * stored values on sight would look like data loss. Only a column that opts in + * gets fixed-width output. + */ +export function formatWithPrecision(value: number, precision: number | undefined): string { + if (!Number.isFinite(value)) return '' + if (precision === undefined) return String(value) + return value.toFixed(clampPrecision(precision)) +} diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index dcdbe58f790..a1c0ca0f20f 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -68,6 +68,26 @@ export interface ColumnDefinition { * single row. Absent means {@link DEFAULT_CURRENCY_CODE}. */ currencyCode?: string + /** + * Decimal places a `number` or `percent` column renders to. Display metadata + * only — the cell keeps its full stored precision, so lowering this rounds + * what is shown and raising it back recovers the original digits. + * + * Absent on a `number` column renders the value as stored, which is what + * every column predating this key does; only an explicit value forces + * fixed-width output. + */ + precision?: number + /** + * Whether a `date` column carries a time of day. + * + * `false` (the default) stores a bare calendar day, `YYYY-MM-DD`, and renders + * it without a timezone conversion — which is the only correct shape for a + * birthday or due date. `true` stores a full ISO instant and renders it in + * the viewer's zone. Changing this rewrites cells, so `date` declares a + * `migrateCellsForMetadata`. + */ + includeTime?: boolean } /** The column `type` discriminator, named so callers don't index into the interface. */ diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index 270ee5e3ee4..6ada4c99f61 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -50,6 +50,8 @@ const FOREIGN_METADATA_VERB: Record = { options: 'define options', multiple: 'be multiple', currencyCode: 'define a currency', + precision: 'define decimal places', + includeTime: 'carry a time of day', } type ValidationSuccess = { valid: true } diff --git a/packages/emcn/src/icons/index.ts b/packages/emcn/src/icons/index.ts index 1f871b6f680..a7bda20dd85 100644 --- a/packages/emcn/src/icons/index.ts +++ b/packages/emcn/src/icons/index.ts @@ -104,9 +104,14 @@ export { Trash2 } from './trash2' export { TriangleAlert } from './triangle-alert' export { TypeBoolean } from './type-boolean' export { TypeCurrency } from './type-currency' +export { TypeDuration } from './type-duration' +export { TypeEmail } from './type-email' export { TypeJson } from './type-json' export { TypeNumber } from './type-number' +export { TypePercent } from './type-percent' +export { TypePhone } from './type-phone' export { TypeText } from './type-text' +export { TypeUrl } from './type-url' export { Undo } from './undo' export { Unlock } from './unlock' export { Upload } from './upload' diff --git a/packages/emcn/src/icons/type-duration.tsx b/packages/emcn/src/icons/type-duration.tsx new file mode 100644 index 00000000000..5f5940e783e --- /dev/null +++ b/packages/emcn/src/icons/type-duration.tsx @@ -0,0 +1,26 @@ +import type { SVGProps } from 'react' + +/** + * Type duration icon component - clock face for duration columns + * @param props - SVG properties including className, fill, etc. + */ +export function TypeDuration(props: SVGProps) { + return ( + + ) +} diff --git a/packages/emcn/src/icons/type-email.tsx b/packages/emcn/src/icons/type-email.tsx new file mode 100644 index 00000000000..c8fb98c3e3d --- /dev/null +++ b/packages/emcn/src/icons/type-email.tsx @@ -0,0 +1,26 @@ +import type { SVGProps } from 'react' + +/** + * Type email icon component - envelope for email columns + * @param props - SVG properties including className, fill, etc. + */ +export function TypeEmail(props: SVGProps) { + return ( + + ) +} diff --git a/packages/emcn/src/icons/type-percent.tsx b/packages/emcn/src/icons/type-percent.tsx new file mode 100644 index 00000000000..c7a03a2fe0d --- /dev/null +++ b/packages/emcn/src/icons/type-percent.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' + +/** + * Type percent icon component - percent sign for percent columns + * @param props - SVG properties including className, fill, etc. + */ +export function TypePercent(props: SVGProps) { + return ( + + ) +} diff --git a/packages/emcn/src/icons/type-phone.tsx b/packages/emcn/src/icons/type-phone.tsx new file mode 100644 index 00000000000..a7d13162d74 --- /dev/null +++ b/packages/emcn/src/icons/type-phone.tsx @@ -0,0 +1,25 @@ +import type { SVGProps } from 'react' + +/** + * Type phone icon component - handset for phone columns + * @param props - SVG properties including className, fill, etc. + */ +export function TypePhone(props: SVGProps) { + return ( + + ) +} diff --git a/packages/emcn/src/icons/type-url.tsx b/packages/emcn/src/icons/type-url.tsx new file mode 100644 index 00000000000..6ad2dc918aa --- /dev/null +++ b/packages/emcn/src/icons/type-url.tsx @@ -0,0 +1,26 @@ +import type { SVGProps } from 'react' + +/** + * Type url icon component - chain link for url columns + * @param props - SVG properties including className, fill, etc. + */ +export function TypeUrl(props: SVGProps) { + return ( + + ) +} From 135512d6b0452204c49108e42c8aa8547c4c7dd2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 11:51:41 -0700 Subject: [PATCH 03/24] feat(tables): colored select options, metadata controls, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select options take a color, stored as a Badge variant name rather than a hex so each resolves to a token pair already tuned for light and dark — a stored hex is picked against one theme and fails in the other, and an unmapped value falls back to currentColor and renders as a black chip. New options cycle the palette by position so an authored option set is distinguishable without colouring each one by hand; existing options stay gray until someone picks. The config sidebar gains the decimal-places and include-time controls, and asks the registry which to show (`typeOwnsMetadataKey`) rather than testing type names — the one leak the skill's grep turned up in this change. The delete-column undo snapshot carried one flattened field per metadata key, so a new key was silently dropped on restore. It now captures `typeMetadataOf(column)` whole. Tests cover coerce/validate round-trips for the new types; the url-scheme and legacy-date cases were confirmed to fail with their guards removed. --- apps/docs/content/docs/en/tables/index.mdx | 17 +- .../column-config-sidebar.tsx | 64 +++++- .../select-field/select-color-picker.tsx | 48 +++++ .../select-field/select-options-editor.tsx | 20 +- .../components/select-field/select-pill.tsx | 11 +- .../components/table-grid/table-grid.tsx | 8 +- apps/sim/hooks/use-table-undo.ts | 9 +- apps/sim/lib/api/contracts/tables.ts | 3 + .../copilot/tools/server/table/user-table.ts | 23 ++- .../__tests__/column-types-contact.test.ts | 191 ++++++++++++++++++ apps/sim/lib/table/types.ts | 49 ++++- apps/sim/stores/table/types.ts | 20 +- 12 files changed, 427 insertions(+), 36 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-color-picker.tsx create mode 100644 apps/sim/lib/table/__tests__/column-types-contact.test.ts diff --git a/apps/docs/content/docs/en/tables/index.mdx b/apps/docs/content/docs/en/tables/index.mdx index 913711ecdfa..713a944e5c1 100644 --- a/apps/docs/content/docs/en/tables/index.mdx +++ b/apps/docs/content/docs/en/tables/index.mdx @@ -23,14 +23,25 @@ Every column has a type, which decides how its values are stored and validated. | **Text** | A free-form string | `"Acme Corp"` | | **Number** | A numeric value | `42` | | **Currency** | An amount in a currency you pick per column | `$1,234.56` | +| **Percent** | A percentage | `25%` | | **Boolean** | `true` or `false` | `true` | -| **Date** | A date | `2026-03-16` | -| **JSON** | An object or array | `{ "tier": "pro" }` | +| **Date** | A date, with or without a time | `2026-03-16` | | **Select** | One of a fixed set of options, or several | `Pro` | +| **Email** | An email address | `person@example.com` | +| **Phone** | A phone number | `+1 555 123 4567` | +| **URL** | A link | `https://sim.ai` | +| **Duration** | A length of time | `1:30:00` | +| **JSON** | An object or array | `{ "tier": "pro" }` | 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. +Currency, Percent, and Duration columns all store a plain number, so filters, sorts, and exports see the amount itself rather than its formatting — `> 50%` and `>= 1h` are ordinary numeric comparisons. Changing a column's currency relabels it; it does not convert the amounts. Number and Percent columns take a decimal-place setting that changes how values are shown without rounding what is stored. + +Email, Phone, and URL columns tidy values as you enter them — addresses are lower-cased, phone numbers stripped to digits, and links given a scheme — so the same value entered two ways matches. URL cells render as clickable links. + +A Date column can carry a time of day or just a calendar date. Turning **Include time** off on a column that already has times will drop them. + +Select options can each be given a color, which is used for the pill shown in the cell. ## Editing a table 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 116b43de286..b6aec3ea4d0 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,11 +6,13 @@ 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 { typeOwnsMetadataKey } from '@/lib/table/column-types' import { DEFAULT_CURRENCY_CODE, getCurrencyOptions, resolveCurrencyCode, } from '@/lib/table/currency' +import { clampPrecision, DEFAULT_PRECISION } from '@/lib/table/precision' import { FieldError, RequiredLabel, @@ -21,7 +23,7 @@ import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types' /** Whether a column type carries an option set. */ function isSelectType(type: ColumnDefinition['type']): boolean { - return type === 'select' + return typeOwnsMetadataKey(type, 'options') } /** @@ -129,6 +131,14 @@ function ColumnConfigBody({ ? resolveCurrencyCode(existingColumn?.currencyCode) : DEFAULT_CURRENCY_CODE ) + const [precisionInput, setPrecisionInput] = useState(() => + clampPrecision(existingColumn?.precision) + ) + const [includeTimeInput, setIncludeTimeInput] = useState(() => + // Absent means a column created before the key existed, and those hold + // instants — so the toggle reflects what the column actually stores. + config.mode === 'edit' ? existingColumn?.includeTime !== false : false + ) const [showValidation, setShowValidation] = useState(false) const [nameError, setNameError] = useState(null) const [optionsError, setOptionsError] = useState(null) @@ -136,7 +146,12 @@ function ColumnConfigBody({ const saveDisabled = updateColumn.isPending || addColumn.isPending const trimmedName = nameInput.trim() const wantsOptions = isSelectType(typeInput) - const wantsCurrency = typeInput === 'currency' + // Which metadata controls to show is a registry question, not a list of type + // names: a type that later gains `precision` gets the control for free, and a + // type that loses it cannot leave a stale control behind. + const wantsCurrency = typeOwnsMetadataKey(typeInput, 'currencyCode') + const wantsPrecision = typeOwnsMetadataKey(typeInput, 'precision') + const wantsIncludeTime = typeOwnsMetadataKey(typeInput, 'includeTime') const trimmedOptions = optionsInput.map((o) => ({ ...o, name: o.name.trim() })) /** Client-side option validation mirroring the server rules; returns an error message or null. */ @@ -171,6 +186,8 @@ function ColumnConfigBody({ ...(wantsOptions ? { options: trimmedOptions } : {}), ...(wantsOptions && multipleInput ? { multiple: true } : {}), ...(wantsCurrency ? { currencyCode: currencyInput } : {}), + ...(wantsPrecision ? { precision: precisionInput } : {}), + ...(wantsIncludeTime ? { includeTime: includeTimeInput } : {}), }) toast.success(`Added "${trimmedName}"`) onClose() @@ -191,6 +208,10 @@ function ColumnConfigBody({ const multipleChanged = wantsOptions && !!existingColumn?.multiple !== multipleInput const currencyChanged = wantsCurrency && resolveCurrencyCode(existingColumn?.currencyCode) !== currencyInput + const precisionChanged = + wantsPrecision && clampPrecision(existingColumn?.precision) !== precisionInput + const includeTimeChanged = + wantsIncludeTime && (existingColumn?.includeTime !== false) !== includeTimeInput const updates: { name?: string @@ -199,6 +220,8 @@ function ColumnConfigBody({ options?: SelectOption[] multiple?: boolean currencyCode?: string + precision?: number + includeTime?: boolean } = { ...(renamed ? { name: trimmedName } : {}), ...(typeChanged ? { type: typeInput } : {}), @@ -209,6 +232,12 @@ function ColumnConfigBody({ ...(wantsCurrency && (typeChanged || currencyChanged) ? { currencyCode: currencyInput } : {}), + ...(wantsPrecision && (typeChanged || precisionChanged) + ? { precision: precisionInput } + : {}), + ...(wantsIncludeTime && (typeChanged || includeTimeChanged) + ? { includeTime: includeTimeInput } + : {}), } if (Object.keys(updates).length === 0) { onClose() @@ -306,6 +335,37 @@ function ColumnConfigBody({ )} + {wantsPrecision && ( + <> + +
+ Decimal places + setPrecisionInput(clampPrecision(Number(e.target.value)))} + /> +
+ + )} + + {wantsIncludeTime && ( + <> + +
+ + setIncludeTimeInput(!!v)} + /> +
+ + )} + {wantsOptions && ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-color-picker.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-color-picker.tsx new file mode 100644 index 00000000000..eccf548ac35 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-color-picker.tsx @@ -0,0 +1,48 @@ +'use client' + +import { Badge, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@sim/emcn' +import { SELECT_OPTION_COLORS, type SelectOptionColor } from '@/lib/table' + +interface SelectColorPickerProps { + color: SelectOptionColor | undefined + onChange: (color: SelectOptionColor) => void + /** Option name, so the trigger's accessible label says which option it colors. */ + optionName: string +} + +/** + * Swatch dropdown for one option's pill color. + * + * Each swatch is a real `Badge` in the variant it selects, so the menu shows the + * exact chrome the pill will have in both themes rather than an approximation + * of it — the badge stays the single owner of its colors. + */ +export function SelectColorPicker({ color, onChange, optionName }: SelectColorPickerProps) { + const current = color ?? 'gray' + return ( + + + + {current} + + + + {SELECT_OPTION_COLORS.map((swatch) => ( + + ))} + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx index 848a0c86921..6ae850e95bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx @@ -4,7 +4,8 @@ import { useEffect, useRef, useState } from 'react' import { Button, ChipInput } from '@sim/emcn' import { X } from '@sim/emcn/icons' import { generateShortId } from '@sim/utils/id' -import type { SelectOption } from '@/lib/table' +import { SELECT_OPTION_COLORS, type SelectOption } from '@/lib/table' +import { SelectColorPicker } from './select-color-picker' interface SelectOptionsEditorProps { options: SelectOption[] @@ -44,10 +45,17 @@ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorPr onChange(options.filter((o) => o.id !== id)) } - /** Typing into the trailing row promotes it to a real option and keeps focus. */ + /** + * Typing into the trailing row promotes it to a real option and keeps focus. + * + * The color cycles through the palette by position rather than defaulting to + * gray, so a freshly authored option set is distinguishable at a glance + * without the user colouring each one by hand. + */ const materialize = (name: string) => { const id = generateShortId() - onChange([...options, { id, name }]) + const color = SELECT_OPTION_COLORS[options.length % SELECT_OPTION_COLORS.length] + onChange([...options, { id, name, color }]) setPendingFocusId(id) } @@ -55,6 +63,11 @@ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorPr
{options.map((option) => (
+ update(option.id, { color })} + optionName={option.name} + /> { if (el) inputRefs.current.set(option.id, el) @@ -86,6 +99,7 @@ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorPr
))}
+ + {option.name} ) 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 bcca45899c4..ff042825644 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 @@ -22,7 +22,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 { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room' @@ -3641,11 +3641,9 @@ 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 + // Without this 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 ? { columnMetadata: typeMetadataOf(entry.def) } : {}), cellData, previousOrder: orderSnapshot, previousWidth, diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 205e52b8b53..70c03e2bf59 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -386,11 +386,10 @@ 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 } : {}), + // Every type-specific key the column carried. A select column + // is rejected without its options, and the cell data restored + // below is keyed by those option ids. + ...action.columnMetadata, position: action.columnPosition, }, { diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 380b4ea3a2c..b91d85991ea 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -35,6 +35,7 @@ import { } from '@/lib/table/constants' import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' import type { ColumnTypeMetadata } from '@/lib/table/types' +import { SELECT_OPTION_COLORS } from '@/lib/table/types' export const domainObjectSchema = () => z.custom(isRecordLike) @@ -51,6 +52,8 @@ export const selectOptionSchema = z.object({ .string() .min(1, 'Option name is required') .max(100, 'Option name must be 100 characters or less'), + /** Pill color; absent renders the neutral gray options used before colors. */ + color: z.enum(SELECT_OPTION_COLORS).optional(), }) export const selectOptionsSchema = z 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 d120d20d415..bfa21493f87 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -80,6 +80,7 @@ import type { Filter, RowData, SelectOption, + SelectOptionColor, SortSpec, TableDefinition, TableDeleteJobPayload, @@ -92,6 +93,7 @@ import type { WorkflowGroupInputMapping, WorkflowGroupOutput, } from '@/lib/table/types' +import { isSelectOptionColor } from '@/lib/table/types' import { markTableUpdateFailed, runTableUpdate } from '@/lib/table/update-runner' import { cancelWorkflowGroupRuns, runWorkflowColumn } from '@/lib/table/workflow-columns' import { @@ -356,12 +358,27 @@ export function normalizeSelectOptionsInput( } const resolveId = (name: string): string => idByName.get(name.toLowerCase()) ?? generateShortId() + // An option's existing color survives a re-send that omits it, so an edit + // that only renames options does not silently reset every pill to gray. + const colorByName = new Map() + for (const option of existing) { + if (option.color) colorByName.set(option.name.toLowerCase(), option.color) + } + const resolveColor = (name: string, supplied: unknown): SelectOptionColor | undefined => { + if (typeof supplied === 'string' && isSelectOptionColor(supplied)) return supplied + return colorByName.get(name.toLowerCase()) + } + return raw.map((entry) => { - if (typeof entry === 'string') return { id: resolveId(entry), name: entry } - const e = (entry ?? {}) as { id?: unknown; name?: unknown } + if (typeof entry === 'string') { + const color = resolveColor(entry, undefined) + return { id: resolveId(entry), name: entry, ...(color ? { color } : {}) } + } + const e = (entry ?? {}) as { id?: unknown; name?: unknown; color?: unknown } const name = typeof e.name === 'string' ? e.name : String(e.name ?? '') const id = typeof e.id === 'string' && e.id.length > 0 ? e.id : resolveId(name) - return { id, name } + const color = resolveColor(name, e.color) + return { id, name, ...(color ? { color } : {}) } }) } diff --git a/apps/sim/lib/table/__tests__/column-types-contact.test.ts b/apps/sim/lib/table/__tests__/column-types-contact.test.ts new file mode 100644 index 00000000000..c5aa6b9118d --- /dev/null +++ b/apps/sim/lib/table/__tests__/column-types-contact.test.ts @@ -0,0 +1,191 @@ +/** + * @vitest-environment node + * + * Round-trip guards for the types added alongside the registry refactor. + * + * The property that matters for each is the same one the registry exists to + * protect: `coerce` is the single write path, so whatever it accepts must be + * something `validateCell` then agrees is valid and `formatForInput` can hand + * back to an editor unchanged. A type that coerces into a shape its own + * validator rejects nulls the cell on the next write, silently. + */ +import { describe, expect, it } from 'vitest' +import { COLUMN_TYPE_REGISTRY, columnTypeById, isValueCompatible } from '@/lib/table/column-types' +import type { ColumnDefinition } from '@/lib/table/types' + +const column = (type: ColumnDefinition['type'], extra: Partial = {}) => + ({ name: 'c', type, ...extra }) as ColumnDefinition + +describe('email', () => { + const col = column('email') + + it.each([ + [' Ada@Example.COM ', 'ada@example.com'], + ['person@example.co.uk', 'person@example.co.uk'], + ['a.b+tag@sub.example.com', 'a.b+tag@sub.example.com'], + ])('normalizes %s to %s', (input, expected) => { + const result = COLUMN_TYPE_REGISTRY.email.coerce(input, col) + expect(result.ok && result.value).toBe(expected) + }) + + it.each(['no-at-sign', 'two @spaces.com', '@example.com', 'a@b', 'a@.com'])( + 'rejects %s', + (input) => { + expect(COLUMN_TYPE_REGISTRY.email.coerce(input, col).ok).toBe(false) + } + ) + + it('case-folds so enrichment matching cannot miss on capitalization', () => { + const upper = COLUMN_TYPE_REGISTRY.email.coerce('ADA@EXAMPLE.COM', col) + const lower = COLUMN_TYPE_REGISTRY.email.coerce('ada@example.com', col) + expect(upper.ok && upper.value).toBe(lower.ok && lower.value) + }) +}) + +describe('phone', () => { + const col = column('phone') + + it.each([ + ['+1 (555) 123-4567', '+15551234567'], + ['555-123-4567', '5551234567'], + ['+44 20 7123 4567', '+442071234567'], + ])('normalizes %s to %s', (input, expected) => { + const result = COLUMN_TYPE_REGISTRY.phone.coerce(input, col) + expect(result.ok && result.value).toBe(expected) + }) + + it('refuses an extension rather than silently truncating to the wrong number', () => { + expect(COLUMN_TYPE_REGISTRY.phone.coerce('555-123-4567 x89', col).ok).toBe(false) + }) + + it.each([['12345'], ['1234567890123456'], ['not a phone']])('rejects %s', (input) => { + expect(COLUMN_TYPE_REGISTRY.phone.coerce(input, col).ok).toBe(false) + }) + + it('keeps a leading + that a numeric cast would have dropped', () => { + const result = COLUMN_TYPE_REGISTRY.phone.coerce('+15551234567', col) + expect(result.ok && String(result.value).startsWith('+')).toBe(true) + expect(columnTypeById('phone').jsonbCast).toBeNull() + }) +}) + +describe('url', () => { + const col = column('url') + + it.each([ + ['sim.ai', 'https://sim.ai/'], + ['https://sim.ai/docs', 'https://sim.ai/docs'], + ['http://example.com', 'http://example.com/'], + ])('normalizes %s to %s', (input, expected) => { + const result = COLUMN_TYPE_REGISTRY.url.coerce(input, col) + expect(result.ok && result.value).toBe(expected) + }) + + it.each(['javascript:alert(1)', 'data:text/html,