Skip to content
3 changes: 2 additions & 1 deletion apps/sim/app/api/table/[tableId]/columns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
msg.includes('incompatible') ||
msg.includes('duplicate') ||
msg.includes('option') ||
msg.includes('currency')
msg.includes('currency') ||
msg.includes('is already type')
) {
return NextResponse.json({ error: msg }, { status: 400 })
}
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/v1/tables/[tableId]/columns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
msg.includes('incompatible') ||
msg.includes('duplicate') ||
msg.includes('option') ||
msg.includes('currency')
msg.includes('currency') ||
msg.includes('is already type')
) {
return NextResponse.json({ error: msg }, { status: 400 })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ function ColumnConfigBody({
value={typeInput}
onChange={(v) => setTypeInput(v as ColumnDefinition['type'])}
placeholder='Select type'
maxHeight={260}
maxHeight={300}
/>
</div>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,11 @@ export function NewColumnDropdown({
const menu = (
<DropdownMenu>
<DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>
<DropdownMenuContent align='start' side='bottom' sideOffset={4}>
{/* Taller than the 240px shared default: the full type list is 9 items
(295px with its separator and padding), so the default cut the last
two off behind a scrollbar. Sized here rather than in the shared
component, which every other dropdown in the app relies on. */}
<DropdownMenuContent align='start' side='bottom' sideOffset={4} className='max-h-[320px]'>
<>
<DropdownMenuItem onSelect={onPickEnrichment}>
<Sparkles className='size-[14px] text-[var(--text-icon)]' />
Expand Down
25 changes: 23 additions & 2 deletions apps/sim/lib/table/__tests__/column-conversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,32 @@ import { describe, expect, it } from 'vitest'
import { COLUMN_TYPE_REGISTRY } from '@/lib/table/column-types'
import {
applyPendingRename,
isValueCompatibleWithType,
isValueCompatibleWithColumn,
selectValueForConversion,
} from '@/lib/table/columns/service'
import { resolveSelectOptionId } from '@/lib/table/select-options'
import type { ColumnDefinition, SelectOption } from '@/lib/table/types'
import type { ColumnDefinition, ColumnType, SelectOption } from '@/lib/table/types'

/**
* Marshals the loose per-type arguments these cases are written in into the
* target column the gate takes. Lives here rather than in the service so
* production has only the whole-column form, which cannot drop a metadata key.
*/
function isValueCompatibleWithType(
value: unknown,
targetType: ColumnType,
targetOptions: SelectOption[] = [],
targetMultiple = false,
targetRequired = false
): boolean {
return isValueCompatibleWithColumn(value, {
name: '',
type: targetType,
options: targetOptions,
multiple: targetMultiple,
required: targetRequired,
})
}

const OPTIONS: SelectOption[] = [
{ id: 'opt_a', name: 'Alpha' },
Expand Down
73 changes: 73 additions & 0 deletions apps/sim/lib/table/__tests__/currency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,79 @@ describe('parseCurrencyInput', () => {
expect(parseCurrencyInput('CHF 1’234.56')).toBe(1234.56)
})

it('reads a lone dot as the decimal point people type', () => {
// Typing `1.234` in a USD cell means 1.234, which the display rounds to
// $1.23 exactly as a spreadsheet does. Reading it as 1,234 would be a
// thousandfold surprise, marker or no marker.
expect(parseCurrencyInput('$1.234', 'USD')).toBe(1.234)
expect(parseCurrencyInput('1.234')).toBe(1.234)
expect(parseCurrencyInput('1.234', 'EUR')).toBe(1.234)
})

it('groups a lone dot only for a zero-decimal currency that came formatted', () => {
// `1.235 ¥` cannot be a fraction of a yen, and is the single lone-separator
// form a formatter emits — but a formatter always emits its marker too.
expect(parseCurrencyInput('1.235 ¥', 'JPY')).toBe(1235)
expect(parseCurrencyInput('JPY 1.235', 'JPY')).toBe(1235)
// Bare, it is someone typing. Inflating that to 1235 would be a silent
// thousandfold error; read literally it stores 1.235 and displays ¥1 —
// wrong in a way the writer can see and fix.
expect(parseCurrencyInput('1.235', 'JPY')).toBe(1.235)
expect(parseCurrencyInput('1.235', 'CLP')).toBe(1.235)
})

it('reads a lone comma as grouping, except where the currency has three decimals', () => {
// `1,500` is fifteen hundred by convention. A three-decimal currency's
// trailing three digits are decimals however the value arrived — which
// matters most for CSV import, where nothing carries a marker.
expect(parseCurrencyInput('1,500', 'USD')).toBe(1500)
expect(parseCurrencyInput('1,500')).toBe(1500)
expect(parseCurrencyInput('0,500', 'KWD')).toBe(0.5)
expect(parseCurrencyInput('0,500 KWD', 'KWD')).toBe(0.5)
expect(parseCurrencyInput('12,000', 'TND')).toBe(12)
expect(parseCurrencyInput('12,000 TND', 'TND')).toBe(12)
})

it('refuses a scale suffix rather than shrinking the value', () => {
// `1.2 M` read as 1.2 would rewrite a column of millions a millionfold too
// small — the same invented-value failure as an identifier, inverted.
expect(parseCurrencyInput('1.2 M')).toBeNull()
expect(parseCurrencyInput('5 K')).toBeNull()
expect(parseCurrencyInput('3.4 bn')).toBeNull()
expect(parseCurrencyInput('10 B')).toBeNull()
// `kr` is a currency marker, not a scale suffix, despite starting with k.
expect(parseCurrencyInput('1 234,56 kr')).toBe(1234.56)
})

it('refuses non-English magnitude words too', () => {
// These are why the marker check is an allowlist. As a denylist each one
// had to be named, and any that was missed read as a bare number — `1,2
// mio` as 1.2 rather than 1.2 million.
expect(parseCurrencyInput('1,2 mio')).toBeNull()
expect(parseCurrencyInput('3,4 mrd')).toBeNull()
expect(parseCurrencyInput('5 tsd')).toBeNull()
expect(parseCurrencyInput('2,5 mln')).toBeNull()
expect(parseCurrencyInput('1,5 bio')).toBeNull()
expect(parseCurrencyInput('7 md')).toBeNull()
// Anything else beside a number is refused by the same rule, so the parser
// no longer has to enumerate what a magnitude word looks like.
expect(parseCurrencyInput('12 units')).toBeNull()
expect(parseCurrencyInput('12 pcs')).toBeNull()
})

it('keeps every ISO code strippable — no code is a magnitude word', () => {
// The allowlist is only safe because these two sets do not overlap. If a
// future ISO code ever collided with a magnitude abbreviation, this fails
// and the marker rule needs a tiebreak.
const codes = new Set(
(Intl as unknown as { supportedValuesOf: (k: string) => string[] }).supportedValuesOf(
'currency'
)
)
const magnitudeWords = ['K', 'M', 'B', 'T', 'BN', 'MN', 'MIO', 'MRD', 'TSD', 'MLN', 'BIO', 'MD']
expect(magnitudeWords.filter((w) => codes.has(w))).toEqual([])
})

it('rejects an identifier whose letters touch its digits', () => {
// The distinguishing rule: a currency marker is always separated from the
// number by a space or a symbol, so letters touching digits mean this is a
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/table/column-types/currency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ export const currencyColumnType: ColumnTypeDefinition = {
typeaheadPattern: /[\d.,\-\p{Sc}]/u,
parseErrorMessage: 'Invalid amount',

coerce(value) {
coerce(value, column) {
// Stored as a bare number, but accepts the formatted shapes an amount
// arrives in — `$1,234.56`, `1 234,56 €`, `(12.00)` — so a paste, CSV
// import, or tool write lands as a number rather than being nulled.
const parsed = parseCurrencyInput(value)
const parsed = parseCurrencyInput(value, column.currencyCode)
Comment thread
waleedlatif1 marked this conversation as resolved.
return parsed === null ? { ok: false } : { ok: true, value: parsed }
},

Expand Down
52 changes: 19 additions & 33 deletions apps/sim/lib/table/columns/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,15 +749,14 @@ export async function updateColumnType(
// once the column is text/number/etc. Check compatibility against the option
// NAME — that's what the cell will actually become (migrated below).
const convertingAwayFromSelect = column.type === 'select' && !isSelectType
// The constraint the column ends up with, which may be arriving in this same
// request. `updateColumnConstraints` runs as its own transaction afterwards,
// so validating against the column's CURRENT flag would let the conversion
// commit and only then fail the constraint.
// The constraint the column ends up with, which may be arriving in this
// same request — this write applies it, so the scan below has to judge
// against the target value rather than the current one.
const targetRequired = !!(data.required ?? column.required)

// Rows missing the key (or holding null/`[]`) are filtered out of `rows`
// entirely, so the loop below can never see them — they have to be counted
// separately, through the same predicate the constraint write will use.
// separately, through the same predicate `applyConstraints` uses.
if (targetRequired) {
const emptyCount = await countEmptyCells(trx, data.tableId, columnKey)
if (emptyCount > 0) {
Expand Down Expand Up @@ -802,15 +801,7 @@ export async function updateColumnType(

const effective = convertingAwayFromSelect ? selectValueForConversion(column, value) : value

if (
!isValueCompatibleWithType(
effective,
data.newType,
targetOptions,
!!targetMultiple,
targetRequired
)
) {
if (!isValueCompatibleWithColumn(effective, convertedColumn)) {
// A cell the target cannot read but that is merely EMPTY is not a
// conversion failure — the write path already turns an unreadable value
// into null on an optional column, so the conversion does the same. Only
Expand Down Expand Up @@ -1331,29 +1322,24 @@ export function selectValueForConversion(column: ColumnDefinition, value: unknow
}

/**
* Checks if a value is compatible with a target column type. For `select`,
* `targetOptions` is the option set the column will carry after the change: a
* value is compatible only if every part of it resolves to one of those options
* (by id or name). This blocks a conversion that would otherwise strand or
* silently drop values on the next row write.
* Checks a value against the column definition the table will end up with.
*
* Takes the whole target column rather than loose per-type arguments: the gate
* has to read the SAME metadata the later coercion does, and a hand-built stub
* silently omits whatever key its author did not think of. Today that key would
* be `currencyCode` — a three-decimal column reads `0,500` as a half dinar
* while a stub without the code reads it as five hundred.
*
* That divergence is currently invisible, because whether an amount parses at
* all does not depend on the currency, only which number it yields — and the
* write-back already coerces against the real column. Passing the real column
* here means it cannot become visible when that stops being true.
*
* Callers converting *away* from `select` must pass the resolved option
* name(s), not the stored ids — see {@link selectValueForConversion}.
*/
export function isValueCompatibleWithType(
value: unknown,
targetType: (typeof COLUMN_TYPES)[number],
targetOptions: SelectOption[] = [],
targetMultiple = false,
targetRequired = false
): boolean {
export function isValueCompatibleWithColumn(value: unknown, target: ColumnDefinition): boolean {
if (value === null || value === undefined) return true
// Each type reads only the metadata it owns.
return isValueCompatible(value, {
name: '',
type: targetType,
options: targetOptions,
multiple: targetMultiple,
required: targetRequired,
})
return isValueCompatible(value, target)
}
Loading
Loading