Skip to content

Commit 8342514

Browse files
committed
feat(tables): add currency column type on a new column-type registry
Adds a `currency` column type, and consolidates the per-type knowledge it would otherwise have been scattered across. **Currency.** Stores a plain number and carries an ISO 4217 `currencyCode` as display metadata. That split is what keeps it cheap: filtering, sorting, uniqueness and CSV export all reuse the numeric paths unchanged, changing a column's currency rewrites no rows, and the public row output stays a number rather than a locale-formatted string consumers would have to reparse. Input accepts the shapes an amount actually arrives in — `$1,234.56`, `1 234,56 €`, `(12.00)` — so pastes, CSV imports and tool writes land as numbers instead of being nulled. **The registry.** Adding this type initially required edits in ~40 places: 32 switch arms under `lib/table`, ~26 UI branches, two hand-maintained icon maps, and a coercion implementation duplicated four times. Every one of those failed silently when missed — a missing `jsonbCastForType` arm compares numbers as text; a missing compatibility arm blocks all conversions. `lib/table/column-types/` now holds one file per type carrying its label, icon, badge colour, storage cast, filter operators, coercion, validation, compatibility and formatting. `Record<ColumnType, …>` on both registries is the completeness gate: adding a type to the union is a compile error naming exactly the two files to fill in, and the interface then requires every field. The 32 switch arms are down to 3. Two duplicates collapse as a consequence: - The client no longer mirrors the server's select id-resolution. Those helpers lived in `validation.ts`, which imports drizzle, so anything reaching them became server-only and the grid hand-rolled its own copy. Extracting them to `select-options.ts` lets both sides share one implementation, so the optimistic cache can no longer disagree with what gets persisted. - The two icon maps become one registry read. It also fixes a live inconsistency it surfaced: currency got a numeric keypad in the grid's inline editor but a plain text field in the row modal. Behaviour-neutral by construction: all 1046 tests in the touched areas pass unchanged, with no test edits.
1 parent 811a39e commit 8342514

50 files changed

Lines changed: 2101 additions & 637 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/en/tables/index.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,16 @@ Every column has a type, which decides how its values are stored and validated.
2222
| --- | --- | --- |
2323
| **Text** | A free-form string | `"Acme Corp"` |
2424
| **Number** | A numeric value | `42` |
25+
| **Currency** | An amount in a currency you pick per column | `$1,234.56` |
2526
| **Boolean** | `true` or `false` | `true` |
2627
| **Date** | A date | `2026-03-16` |
2728
| **JSON** | An object or array | `{ "tier": "pro" }` |
29+
| **Select** | One of a fixed set of options, or several | `Pro` |
2830

2931
Types are enforced as you enter values, so a Number column only takes numbers.
3032

33+
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.
34+
3135
## Editing a table
3236

3337
Open the **Tables** section in the sidebar and click **New table** to create one. Add columns from the column header, type into a cell to edit it, and paste rows from a spreadsheet to bulk-load. Filter and sort from the toolbar without changing the underlying data. The editor has full keyboard support; see [keyboard shortcuts](/keyboard-shortcuts).

apps/sim/app/api/table/[tableId]/columns/route.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
deleteColumn,
1616
renameColumn,
1717
updateColumnConstraints,
18+
updateColumnCurrency,
1819
updateColumnOptions,
1920
updateColumnType,
2021
} from '@/lib/table'
@@ -154,12 +155,25 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
154155
newType: updates.type as NonNullable<typeof updates.type>,
155156
...(updates.options !== undefined ? { options: updates.options } : {}),
156157
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
158+
...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}),
157159
// Forwarded so the conversion validates against the constraint this
158160
// same request is about to set, not the column's current one.
159161
...(updates.required !== undefined ? { required: updates.required } : {}),
160162
},
161163
requestId
162164
)
165+
} else if (updates.currencyCode !== undefined) {
166+
// Re-denominating an existing currency column: schema-only, no cell
167+
// rewrite. Reached only when the type is unchanged — a conversion INTO
168+
// currency carries the code through `updateColumnType` above.
169+
updatedTable = await updateColumnCurrency(
170+
{
171+
tableId,
172+
columnName: updates.name ?? validated.columnName,
173+
currencyCode: updates.currencyCode,
174+
},
175+
requestId
176+
)
163177
} else if (updates.options !== undefined || updates.multiple !== undefined) {
164178
updatedTable = await updateColumnOptions(
165179
{
@@ -217,7 +231,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
217231
msg.includes('exceeds maximum') ||
218232
msg.includes('incompatible') ||
219233
msg.includes('duplicate') ||
220-
msg.includes('option')
234+
msg.includes('option') ||
235+
msg.includes('currency')
221236
) {
222237
return NextResponse.json({ error: msg }, { status: 400 })
223238
}

apps/sim/app/api/table/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,5 +341,6 @@ export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
341341
...(col.workflowGroupId ? { workflowGroupId: col.workflowGroupId } : {}),
342342
...(col.options ? { options: col.options } : {}),
343343
...(col.multiple ? { multiple: true } : {}),
344+
...(col.currencyCode ? { currencyCode: col.currencyCode } : {}),
344345
}
345346
}

apps/sim/app/api/v1/tables/[tableId]/columns/route.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
deleteColumn,
1515
renameColumn,
1616
updateColumnConstraints,
17+
updateColumnCurrency,
1718
updateColumnOptions,
1819
updateColumnType,
1920
} from '@/lib/table'
@@ -188,12 +189,25 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
188189
newType: updates.type as NonNullable<typeof updates.type>,
189190
...(updates.options !== undefined ? { options: updates.options } : {}),
190191
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
192+
...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}),
191193
// Forwarded so the conversion validates against the constraint this
192194
// same request is about to set, not the column's current one.
193195
...(updates.required !== undefined ? { required: updates.required } : {}),
194196
},
195197
requestId
196198
)
199+
} else if (updates.currencyCode !== undefined) {
200+
// Re-denominating an existing currency column: schema-only, no cell
201+
// rewrite. Reached only when the type is unchanged — a conversion INTO
202+
// currency carries the code through `updateColumnType` above.
203+
updatedTable = await updateColumnCurrency(
204+
{
205+
tableId,
206+
columnName: updates.name ?? validated.columnName,
207+
currencyCode: updates.currencyCode,
208+
},
209+
requestId
210+
)
197211
} else if (updates.options !== undefined || updates.multiple !== undefined) {
198212
updatedTable = await updateColumnOptions(
199213
{
@@ -262,7 +276,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
262276
msg.includes('exceeds maximum') ||
263277
msg.includes('incompatible') ||
264278
msg.includes('duplicate') ||
265-
msg.includes('option')
279+
msg.includes('option') ||
280+
msg.includes('currency')
266281
) {
267282
return NextResponse.json({ error: msg }, { status: 400 })
268283
}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { X } from '@sim/emcn/icons'
66
import { toError } from '@sim/utils/errors'
77
import { findValidationIssue, isValidationError } from '@/lib/api/client/errors'
88
import type { ColumnDefinition, SelectOption } from '@/lib/table'
9+
import { CURRENCY_OPTIONS, DEFAULT_CURRENCY_CODE, resolveCurrencyCode } from '@/lib/table/currency'
910
import {
1011
FieldError,
1112
RequiredLabel,
@@ -19,6 +20,15 @@ function isSelectType(type: ColumnDefinition['type']): boolean {
1920
return type === 'select'
2021
}
2122

23+
/**
24+
* Picker entries, built once at module load: the option list is derived from the
25+
* runtime's currency data and never varies per column.
26+
*/
27+
const CURRENCY_COMBOBOX_OPTIONS = CURRENCY_OPTIONS.map((c) => ({
28+
value: c.code,
29+
label: `${c.code} · ${c.name}`,
30+
}))
31+
2232
function optionsEqual(a: SelectOption[], b: SelectOption[]): boolean {
2333
return JSON.stringify(a) === JSON.stringify(b)
2434
}
@@ -110,13 +120,19 @@ function ColumnConfigBody({
110120
const [multipleInput, setMultipleInput] = useState<boolean>(() =>
111121
config.mode === 'edit' ? !!existingColumn?.multiple : false
112122
)
123+
const [currencyInput, setCurrencyInput] = useState<string>(() =>
124+
config.mode === 'edit'
125+
? resolveCurrencyCode(existingColumn?.currencyCode)
126+
: DEFAULT_CURRENCY_CODE
127+
)
113128
const [showValidation, setShowValidation] = useState(false)
114129
const [nameError, setNameError] = useState<string | null>(null)
115130
const [optionsError, setOptionsError] = useState<string | null>(null)
116131

117132
const saveDisabled = updateColumn.isPending || addColumn.isPending
118133
const trimmedName = nameInput.trim()
119134
const wantsOptions = isSelectType(typeInput)
135+
const wantsCurrency = typeInput === 'currency'
120136
const trimmedOptions = optionsInput.map((o) => ({ ...o, name: o.name.trim() }))
121137

122138
/** Client-side option validation mirroring the server rules; returns an error message or null. */
@@ -150,6 +166,7 @@ function ColumnConfigBody({
150166
...(!wantsOptions && uniqueInput ? { unique: true } : {}),
151167
...(wantsOptions ? { options: trimmedOptions } : {}),
152168
...(wantsOptions && multipleInput ? { multiple: true } : {}),
169+
...(wantsCurrency ? { currencyCode: currencyInput } : {}),
153170
})
154171
toast.success(`Added "${trimmedName}"`)
155172
onClose()
@@ -168,20 +185,26 @@ function ColumnConfigBody({
168185
const optionsChanged =
169186
wantsOptions && !optionsEqual(existingColumn?.options ?? [], trimmedOptions)
170187
const multipleChanged = wantsOptions && !!existingColumn?.multiple !== multipleInput
188+
const currencyChanged =
189+
wantsCurrency && resolveCurrencyCode(existingColumn?.currencyCode) !== currencyInput
171190

172191
const updates: {
173192
name?: string
174193
type?: ColumnDefinition['type']
175194
unique?: boolean
176195
options?: SelectOption[]
177196
multiple?: boolean
197+
currencyCode?: string
178198
} = {
179199
...(renamed ? { name: trimmedName } : {}),
180200
...(typeChanged ? { type: typeInput } : {}),
181201
...(uniqueChanged ? { unique: uniqueInput } : {}),
182202
...(uniqueCleared ? { unique: false } : {}),
183203
...(wantsOptions && (typeChanged || optionsChanged) ? { options: trimmedOptions } : {}),
184204
...(wantsOptions && (typeChanged || multipleChanged) ? { multiple: multipleInput } : {}),
205+
...(wantsCurrency && (typeChanged || currencyChanged)
206+
? { currencyCode: currencyInput }
207+
: {}),
185208
}
186209
if (Object.keys(updates).length === 0) {
187210
onClose()
@@ -261,6 +284,24 @@ function ColumnConfigBody({
261284
</>
262285
)}
263286

287+
{wantsCurrency && (
288+
<>
289+
<FieldDivider />
290+
<div className='flex flex-col gap-[9.5px]'>
291+
<RequiredLabel>Currency</RequiredLabel>
292+
<ChipCombobox
293+
options={CURRENCY_COMBOBOX_OPTIONS}
294+
value={currencyInput}
295+
onChange={setCurrencyInput}
296+
placeholder='Select currency'
297+
searchable
298+
searchPlaceholder='Search currencies'
299+
maxHeight={260}
300+
/>
301+
</div>
302+
</>
303+
)}
304+
264305
{wantsOptions && (
265306
<>
266307
<FieldDivider />

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
11
import type React from 'react'
2-
import {
3-
Calendar as CalendarIcon,
4-
PlayOutline,
5-
TagIcon,
6-
TypeBoolean,
7-
TypeJson,
8-
TypeNumber,
9-
TypeText,
10-
} from '@sim/emcn/icons'
2+
import { PlayOutline } from '@sim/emcn/icons'
113
import type { ColumnDefinition } from '@/lib/table'
4+
import { ALL_COLUMN_TYPES } from '@/lib/table/column-types'
125

136
/**
147
* UI-only column type. `'workflow'` is the virtual entry users pick from the
@@ -23,13 +16,17 @@ export interface ColumnTypeOption {
2316
icon: React.ComponentType<{ className?: string }>
2417
}
2518

19+
/**
20+
* Real column types come from the registry — adding one there makes it appear
21+
* in every picker automatically. `workflow` is appended because it is a UI
22+
* affordance, not a storable type.
23+
*/
2624
export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [
27-
{ type: 'string', label: 'Text', icon: TypeText },
28-
{ type: 'number', label: 'Number', icon: TypeNumber },
29-
{ type: 'boolean', label: 'Boolean', icon: TypeBoolean },
30-
{ type: 'date', label: 'Date', icon: CalendarIcon },
31-
{ type: 'json', label: 'JSON', icon: TypeJson },
32-
{ type: 'select', label: 'Select', icon: TagIcon },
25+
...ALL_COLUMN_TYPES.map((definition) => ({
26+
type: definition.id,
27+
label: definition.label,
28+
icon: definition.icon,
29+
})),
3330
{ type: 'workflow', label: 'Workflow', icon: PlayOutline },
3431
]
3532

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { createLogger } from '@sim/logger'
1818
import { getErrorMessage } from '@sim/utils/errors'
1919
import { useParams } from 'next/navigation'
2020
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
21+
import { columnTypeOf } from '@/lib/table/column-types'
22+
import { resolveCurrencyCode } from '@/lib/table/currency'
2123
import { useTimezone } from '@/hooks/queries/general-settings'
2224
import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables'
2325
import {
@@ -209,7 +211,14 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
209211
)}
210212
</>
211213
)
212-
const hint = `Type: ${column.type}${column.required ? '' : ' (optional)'}`
214+
// Currency names its code — the modal edits the bare amount, so without it
215+
// there is nothing on screen saying which currency the number is in.
216+
const typeLabel =
217+
column.type === 'currency'
218+
? `currency (${resolveCurrencyCode(column.currencyCode)})`
219+
: column.type
220+
const hint = `Type: ${typeLabel}${column.required ? '' : ' (optional)'}`
221+
const definition = columnTypeOf(column)
213222

214223
if (column.type === 'boolean') {
215224
return (
@@ -290,7 +299,9 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
290299
title={title}
291300
required={column.required}
292301
hint={hint}
293-
inputType={column.type === 'number' ? 'number' : 'text'}
302+
// Registry-driven, so a numeric type can't get the numeric keypad in the
303+
// grid's inline editor but a plain text field here (currency did).
304+
inputType={definition.inputMode === 'decimal' ? 'number' : 'text'}
294305
value={formatValueForInput(value, column.type)}
295306
onChange={onChange}
296307
placeholder={`Enter ${column.name}`}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn'
66
import { parse } from 'tldts'
77
import { faviconUrl } from '@/lib/core/utils/favicon'
88
import type { RowExecutionMetadata, SelectOption } from '@/lib/table'
9+
import { columnTypeOf } from '@/lib/table/column-types'
910
import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
1011
import { storageToDisplay } from '../../../utils'
1112
import { resolveSelectOptions, SelectPill } from '../../select-field'
@@ -128,6 +129,13 @@ export function resolveCellRender({
128129
return { kind: 'select', options: resolveSelectOptions(column, value) }
129130
}
130131
if (isNull) return { kind: 'empty' }
132+
// Formatted here rather than in a render branch because the symbol and
133+
// fraction digits come from the COLUMN's currency, which the render switch
134+
// (keyed on kind alone) no longer has. Renders as plain text — a currency
135+
// cell is a number cell with a symbol, so it stays left-aligned like one.
136+
if (column.type === 'currency') {
137+
return { kind: 'text', text: columnTypeOf(column).formatForDisplay(value, column) }
138+
}
131139
if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) }
132140
if (column.type === 'date') return { kind: 'date', text: String(value) }
133141
if (column.type === 'string') {

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type React from 'react'
44
import { useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react'
55
import { Button } from '@sim/emcn'
66
import type { TableRow as TableRowType } from '@/lib/table'
7+
import { columnTypeOf } from '@/lib/table/column-types'
78
import { useTimezone } from '@/hooks/queries/general-settings'
89
import type { EditingCell, SaveReason } from '../../../types'
910
import {
@@ -74,6 +75,9 @@ export function ExpandedCellPopover({
7475
if (target.column.type === 'date' && typeof value === 'string') {
7576
return storageToDisplay(value, { seconds: true })
7677
}
78+
if (target.column.type === 'currency') {
79+
return columnTypeOf(target.column).formatForDisplay(value, target.column)
80+
}
7781
if (typeof value === 'string') return value
7882
return JSON.stringify(value, null, 2)
7983
}, [target])
@@ -231,14 +235,12 @@ function ExpandedCellEditor({
231235
setParseError('Invalid JSON')
232236
return
233237
}
234-
/** `cleanCellValue` nulls unparseable dates/numbers instead of throwing — reject rather than silently clear. */
235-
if (
236-
cleaned === null &&
237-
draftValue.trim() !== '' &&
238-
(column.type === 'date' || column.type === 'number')
239-
) {
240-
setParseError(column.type === 'date' ? 'Invalid date' : 'Invalid number')
241-
return
238+
if (cleaned === null && draftValue.trim() !== '') {
239+
const message = columnTypeOf(column).parseErrorMessage
240+
if (message) {
241+
setParseError(message)
242+
return
243+
}
242244
}
243245
onSave(rowId, column.key, cleaned, 'blur')
244246
onClose()

0 commit comments

Comments
 (0)