diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index 19289a0198c..5cad8c5e610 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -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 }) } 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 56caed8353e..d1a507fa195 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -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 }) } 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 af2a0c797c6..116b43de286 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 @@ -282,7 +282,7 @@ function ColumnConfigBody({ value={typeInput} onChange={(v) => setTypeInput(v as ColumnDefinition['type'])} placeholder='Select type' - maxHeight={260} + maxHeight={300} /> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx index 031aa4cbe2b..161b4a32a51 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx @@ -86,7 +86,11 @@ export function NewColumnDropdown({ const menu = ( {triggerButton} - + {/* 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. */} + <> diff --git a/apps/sim/lib/table/__tests__/column-conversion.test.ts b/apps/sim/lib/table/__tests__/column-conversion.test.ts index 3e7c1bf8a55..83903b38761 100644 --- a/apps/sim/lib/table/__tests__/column-conversion.test.ts +++ b/apps/sim/lib/table/__tests__/column-conversion.test.ts @@ -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' }, diff --git a/apps/sim/lib/table/__tests__/currency.test.ts b/apps/sim/lib/table/__tests__/currency.test.ts index e7b7802552b..836200fe5c8 100644 --- a/apps/sim/lib/table/__tests__/currency.test.ts +++ b/apps/sim/lib/table/__tests__/currency.test.ts @@ -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 diff --git a/apps/sim/lib/table/column-types/currency.ts b/apps/sim/lib/table/column-types/currency.ts index 16ee515f830..86485a265d3 100644 --- a/apps/sim/lib/table/column-types/currency.ts +++ b/apps/sim/lib/table/column-types/currency.ts @@ -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) return parsed === null ? { ok: false } : { ok: true, value: parsed } }, diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index d8f627fc117..2ed1dd3cbda 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -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) { @@ -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 @@ -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) } diff --git a/apps/sim/lib/table/currency.ts b/apps/sim/lib/table/currency.ts index c4aeb97e09b..16a508c4957 100644 --- a/apps/sim/lib/table/currency.ts +++ b/apps/sim/lib/table/currency.ts @@ -24,19 +24,81 @@ const BIDI_MARKS = /[\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/g /** Minus-sign characters `Intl` emits in place of the ASCII hyphen. */ const UNICODE_MINUS = /[\u2212\u2012\u2013\uFE63\uFF0D]/g +/** + * A magnitude suffix (`1.2 M`, `5 K`, `3.4 bn`), used only on runtimes that + * cannot enumerate currency codes \u2014 see {@link isCurrencyMarkerLetters}. Every + * other runtime rejects these by not recognising them, so this list does not + * have to be complete. + */ +const SCALE_SUFFIX = /^(?:k|m|b|t|bn|mn|tn|mm|mil|bil|mio|mrd|bio|tsd|mln|md)$/i + /** A letter directly adjacent to a digit: an identifier, not an amount. */ const LETTER_TOUCHING_DIGIT = /\p{L}\d|\d\p{L}/u +/** + * Currency markers people type that are not ISO 4217 codes. Upper-cased for + * comparison; `\u0141` and `\u010c` upper-case as expected. + */ +const NON_ISO_CURRENCY_MARKERS = new Set([ + 'KR', + 'Z\u0141', + 'K\u010c', + 'LEI', + 'LV', + 'FT', + 'KN', + 'RM', + 'RP', + 'TL', + 'DIN', + 'SR', +]) + +/** + * Whether a bare letter token sitting beside a number is a currency marker. + * + * An allowlist, deliberately. The alternative is a denylist of everything that + * is *not* a currency, which has to guess every magnitude abbreviation in every + * language \u2014 `mio`, `mrd`, `tsd`, `mln`, `bio` \u2014 and silently divides the value + * by a million for each one it has not thought of. An unknown token now simply + * fails to strip, so the leftover letters fail {@link AMOUNT_SHAPE} and the + * input is rejected rather than quietly rescaled. + * + * No ISO code collides with a magnitude abbreviation, so nothing legitimate is + * lost \u2014 `lib/table/__tests__/currency.test.ts` pins that. + * + * A runtime that cannot enumerate codes keeps the old permissive behaviour, + * minus the magnitude words it knows by name; rejecting every letter marker + * there would break `USD 12.50` outright. + */ +function isCurrencyMarkerLetters(letters: string): boolean { + const upper = letters.toUpperCase() + if (NON_ISO_CURRENCY_MARKERS.has(upper)) return true + if (supportedCurrencyCodes === null) return !SCALE_SUFFIX.test(upper) + return supportedCurrencyCodes.has(upper) +} + /** * A leading currency marker: up to three letters and/or a symbol, optionally - * behind a sign. The sign is captured so `-$12.50` keeps it. + * behind a sign. The sign is captured so `-$12.50` keeps it, and the letters + * are captured so {@link isCurrencyMarkerLetters} can vet them. */ const CURRENCY_MARKER_PREFIX = - /^[\s\u00a0\u202f]*([+-]?)[\s\u00a0\u202f]*(?:\p{Sc}\p{L}{0,3}|\p{L}{1,3}\p{Sc}?)[\s\u00a0\u202f]*/u + /^[\s\u00a0\u202f]*([+-]?)[\s\u00a0\u202f]*(?:\p{Sc}\p{L}{0,3}|(\p{L}{1,3})(\p{Sc})?)[\s\u00a0\u202f]*/u /** The same, trailing. */ const CURRENCY_MARKER_SUFFIX = - /[\s\u00a0\u202f]*(?:\p{Sc}\p{L}{0,3}|\p{L}{1,3}\p{Sc}?)\.?[\s\u00a0\u202f]*$/u + /[\s\u00a0\u202f]*(?:\p{Sc}\p{L}{0,3}|(\p{L}{1,3})(\p{Sc})?)\.?[\s\u00a0\u202f]*$/u + +/** + * Whether a matched marker may be stripped. A marker carrying a currency + * SYMBOL needs no vetting — no magnitude abbreviation contains one — and + * `letters === undefined` means the symbol-led alternative matched. + */ +function isStrippableMarker(letters: string | undefined, symbol: string | undefined): boolean { + if (letters === undefined || symbol !== undefined) return true + return isCurrencyMarkerLetters(letters) +} /** Only digits and separators, with at least one digit. */ const AMOUNT_SHAPE = /^[+-]?[\d.,]*\d[\d.,]*$/ @@ -171,15 +233,23 @@ export function getCurrencyOptions(): readonly CurrencyOption[] { * therefore read as fifteen hundred — a known ambiguity that resolves in favor * of the far more common reading. * - * Reads ASCII digits only. Locales that format with their own numeral systems - * (Arabic-Indic `١٢٣`, for instance) are rejected rather than misread — - * supporting them is a wider decision than this type, since it would also - * touch `number`, display, and sorting. + * Two input families are deliberately refused rather than guessed at, because + * both would otherwise be misread as an amount rather than rejected: + * + * - Markers written flush against the digits (`Rp12,00`). A letter touching a + * digit is the only thing separating a currency marker from a part number, + * and reading `SKU400` as 400 invents a value where refusing merely + * inconveniences. Markers separated by a space or a symbol all work. + * - Locales formatting with their own numeral systems (Arabic-Indic `١٢٣`). + * Supporting them is a wider decision than this type, since it would also + * touch `number`, display, and sorting. + * + * Both fail closed — `null`, never a wrong number. * * Returns `null` when no amount can be read, so callers can distinguish * "unparseable" from a legitimate `0`. */ -export function parseCurrencyInput(raw: unknown): number | null { +export function parseCurrencyInput(raw: unknown, currencyCode?: string): number | null { if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null if (typeof raw !== 'string') return null @@ -220,13 +290,24 @@ export function parseCurrencyInput(raw: unknown): number | null { if (LETTER_TOUCHING_DIGIT.test(body)) return null // Strip the currency marker: up to three letters (an ISO code, `kr`, `zł`) - // optionally joined to a symbol (`R$`, `CHF`), at either end. Bounded at - // three so prose does not qualify — `Revenue 5` keeps its letters and is - // rejected below. - const cleaned = body - .replace(CURRENCY_MARKER_PREFIX, '$1') - .replace(CURRENCY_MARKER_SUFFIX, '') - .replace(/[\s\u00a0\u202f\u2019']/gu, '') + // optionally joined to a symbol (`R$`, `CHF`), at either end. Only a marker + // that actually NAMES a currency is stripped — a magnitude word like `1.2 M` + // or `3,4 mrd` is left in place, so it fails the amount-shape check below + // instead of silently reading as 1.2, orders of magnitude too small. + // Whether a marker was actually stripped decides one ambiguous case below — + // see {@link loneSeparatorIsGrouping}. + let hadMarker = false + const withoutPrefix = body.replace(CURRENCY_MARKER_PREFIX, (match, sign, letters, symbol) => { + if (!isStrippableMarker(letters, symbol)) return match + hadMarker = true + return sign + }) + const withoutMarkers = withoutPrefix.replace(CURRENCY_MARKER_SUFFIX, (match, letters, symbol) => { + if (!isStrippableMarker(letters, symbol)) return match + hadMarker = true + return '' + }) + const cleaned = withoutMarkers.replace(/[\s\u00a0\u202f\u2019']/gu, '') // What remains must be ONLY digits and separators. Anything else — a // US-format date, leftover prose — is not an amount. if (!AMOUNT_SHAPE.test(cleaned)) return null @@ -246,19 +327,25 @@ export function parseCurrencyInput(raw: unknown): number | null { if (decimalParts.length > 1 || !hasValidGrouping(integerPart, groupSeparator)) return null normalized = `${integerPart.split(groupSeparator).join('')}.${decimalParts[0]}` } else if (lastComma !== -1) { - // A single comma followed by exactly three digits is grouping (`1,500`); - // anything else is a decimal comma (`1,50`). - const grouping = digitsAndSeps.indexOf(',') !== lastComma || /,\d{3}$/.test(digitsAndSeps) + const repeated = digitsAndSeps.indexOf(',') !== lastComma + const grouping = + repeated || loneSeparatorIsGrouping(digitsAndSeps, ',', currencyCode, hadMarker) if (grouping) { if (!hasValidGrouping(digitsAndSeps, ',')) return null normalized = digitsAndSeps.split(',').join('') } else { normalized = digitsAndSeps.replace(',', '.') } - } else if (lastDot !== -1 && digitsAndSeps.indexOf('.') !== lastDot) { - // More than one dot can only be grouping: `1.234.567`. - if (!hasValidGrouping(digitsAndSeps, '.')) return null - normalized = digitsAndSeps.split('.').join('') + } else if (lastDot !== -1) { + const repeated = digitsAndSeps.indexOf('.') !== lastDot + const grouping = + repeated || loneSeparatorIsGrouping(digitsAndSeps, '.', currencyCode, hadMarker) + if (grouping) { + if (!hasValidGrouping(digitsAndSeps, '.')) return null + normalized = digitsAndSeps.split('.').join('') + } else { + normalized = digitsAndSeps + } } else { normalized = digitsAndSeps } @@ -268,6 +355,49 @@ export function parseCurrencyInput(raw: unknown): number | null { return negative ? -parsed : parsed } +/** + * Whether a lone separator followed by exactly three digits is grouping rather + * than a decimal point. + * + * The separator decides, tempered by what the currency can express: + * + * - A **dot** is the decimal point in the notation most people type, so it + * stays a decimal — typing `1.234` in a USD cell means 1.234, which the + * display then rounds to `$1.23`, exactly as a spreadsheet does. Reading it + * as 1,234 would be a thousandfold surprise. + * - A **comma** is grouping by convention — `1,500` is fifteen hundred, not one + * and a half. The exception: a currency with three decimal places (KWD, TND) + * legitimately ends in three digits after its separator, so `0,500` is a half. + * + * The one case where a dot groups is a currency with NO decimal places, and + * only when a currency marker came with it. `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. A bare `1.235` is someone typing, and inflating + * that to 1235 is a silent thousandfold error, where reading it literally stores + * 1.235 and displays `¥1` — wrong in a way the writer can see and correct. + * + * The marker carries no information for any other currency: one with one or two + * decimal places always formats with BOTH separators, so a lone separator there + * never came from a formatter in the first place. + */ +function loneSeparatorIsGrouping( + digitsAndSeps: string, + separator: string, + currencyCode: string | undefined, + hadMarker: boolean +): boolean { + if (!new RegExp(`\\${separator}\\d{3}$`).test(digitsAndSeps)) return false + const fractionDigits = currencyFractionDigits(currencyCode) + return separator === ',' ? fractionDigits !== 3 : fractionDigits === 0 && hadMarker +} + +/** A currency's conventional decimal places, defaulting to 2 when unknown. */ +function currencyFractionDigits(currencyCode: string | undefined): number { + if (!currencyCode) return 2 + const formatter = currencyFormatter(resolveCurrencyCode(currencyCode), undefined) + return formatter?.resolvedOptions().maximumFractionDigits ?? 2 +} + /** * Formatters are cached by locale + code: a grid paints thousands of currency * cells per scroll, and constructing an `Intl.NumberFormat` per cell is orders diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts index 2943870e899..4ef9a6ef375 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -145,6 +145,15 @@ describe('import', () => { expect(coerceValue('ask sales', 'currency')).toBeNull() }) + it('reads an imported amount against the column currency', () => { + // Nothing in a CSV carries a currency marker, so the column's own code is + // the only signal for a three-decimal currency: `0,500` KWD is a half, + // not five hundred. + expect(coerceValue('0,500', 'currency', { currencyCode: 'KWD' })).toBe(0.5) + expect(coerceValue('12,000', 'currency', { currencyCode: 'TND' })).toBe(12) + expect(coerceValue('1,500', 'currency', { currencyCode: 'USD' })).toBe(1500) + }) + it('coerces booleans strictly', () => { expect(coerceValue('true', 'boolean')).toBe(true) expect(coerceValue('FALSE', 'boolean')).toBe(false) diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index e01147c36f9..02c8a6e431a 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -385,7 +385,7 @@ export function inferSchemaFromCsv( export function coerceValue( value: unknown, colType: CsvColumnType, - options?: NormalizeDateCellOptions + options?: NormalizeDateCellOptions & { currencyCode?: string } ): string | number | boolean | null | Record | unknown[] { if (value === null || value === undefined || value === '') return null switch (colType) { @@ -394,9 +394,11 @@ export function coerceValue( return Number.isNaN(n) ? null : n } // Importing into an existing currency column: the file carries the - // formatted amount (`$1,234.56`) but the cell stores a bare number. + // formatted amount (`$1,234.56`) but the cell stores a bare number. The + // column's currency is forwarded because it decides how a lone separator + // reads — a three-decimal currency's `0,500` is a half, not five hundred. case 'currency': - return parseCurrencyInput(value) + return parseCurrencyInput(value, options?.currencyCode) case 'boolean': { const s = String(value).toLowerCase() if (s === 'true') return true @@ -590,7 +592,10 @@ export function coerceRowsForTable( const col = colByName.get(colName) if (!col) continue const colType = (col.type as CsvColumnType) ?? 'string' - coerced[getColumnId(col)] = coerceValue(value, colType, options) as RowData[string] + coerced[getColumnId(col)] = coerceValue(value, colType, { + ...options, + ...(col.currencyCode !== undefined ? { currencyCode: col.currencyCode } : {}), + }) as RowData[string] } return coerced })