Skip to content

Commit a7b78af

Browse files
committed
fix(tables): clamp ratings on server coercion, lazy locale formatters, case-insensitive select match in row modal
1 parent ff4d5f5 commit a7b78af

7 files changed

Lines changed: 69 additions & 31 deletions

File tree

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,15 +255,19 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
255255
if (column.type === 'select') {
256256
const options = column.options ?? []
257257
const current = typeof value === 'string' ? value : ''
258+
// Match the stored value case-insensitively (same as the grid's tag
259+
// rendering) so a casing variant selects its canonical option instead of
260+
// appearing as a duplicate entry.
261+
const matched = options.find((option) => option.toLowerCase() === current.toLowerCase())
258262
const selectOptions = [
259263
...options.map((option) => ({ label: option, value: option })),
260-
...(current && !options.includes(current) ? [{ label: current, value: current }] : []),
264+
...(current && matched === undefined ? [{ label: current, value: current }] : []),
261265
]
262266
return (
263267
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
264268
<ChipCombobox
265269
options={selectOptions}
266-
value={current}
270+
value={matched ?? current}
267271
onChange={(v) => onChange(v)}
268272
placeholder='Select option'
269273
maxHeight={260}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ import { Badge, Checkbox, Tooltip } from '@/components/emcn'
88
import { Mail } from '@/components/emcn/icons'
99
import { cn } from '@/lib/core/utils/cn'
1010
import type { RowExecutionMetadata } from '@/lib/table'
11+
import { RATING_MAX } from '@/lib/table/constants'
1112
import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
1213
import {
1314
formatCurrencyDisplay,
1415
formatPercentDisplay,
15-
RATING_MAX,
1616
selectBadgeVariant,
1717
storageToDisplay,
1818
} from '../../../utils'

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { cn } from '@/lib/core/utils/cn'
1313
import { captureEvent } from '@/lib/posthog/client'
1414
import type { ColumnDefinition, Filter, TableRow as TableRowType, WorkflowGroup } from '@/lib/table'
1515
import { getColumnId } from '@/lib/table/column-keys'
16-
import { getColumnStorageType, TABLE_LIMITS } from '@/lib/table/constants'
16+
import { getColumnStorageType, RATING_MAX, TABLE_LIMITS } from '@/lib/table/constants'
1717
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
1818
import {
1919
useAddTableColumn,
@@ -38,7 +38,6 @@ import {
3838
cleanCellValue,
3939
formatCurrencyDisplay,
4040
formatPercentDisplay,
41-
RATING_MAX,
4241
generateColumnName as sharedGenerateColumnName,
4342
storageToDisplay,
4443
} from '../../utils'

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,34 @@
11
import type { ColumnDefinition } from '@/lib/table'
2-
import { getColumnStorageType } from '@/lib/table/constants'
3-
4-
/** Ratings range 0..RATING_MAX and render as RATING_MAX stars. */
5-
export const RATING_MAX = 5
6-
7-
/** Browser locale for number formatting; `en-US` outside the browser. */
8-
const DISPLAY_LOCALE = typeof navigator === 'undefined' ? 'en-US' : navigator.language
9-
10-
const CURRENCY_FORMATTER = new Intl.NumberFormat(DISPLAY_LOCALE, {
11-
style: 'currency',
12-
currency: 'USD',
13-
})
14-
const PERCENT_FORMATTER = new Intl.NumberFormat(DISPLAY_LOCALE, { maximumFractionDigits: 2 })
2+
import { getColumnStorageType, RATING_MAX } from '@/lib/table/constants'
153

164
/**
17-
* Formats a currency cell's numeric value for display in the user's locale.
18-
* USD is the default display currency until per-column currency config exists.
5+
* Number formatters for currency/percent cells, created lazily on first
6+
* format call. Cell values only render after the client-side row fetch, so
7+
* these always initialize in the browser with `navigator.language` — never at
8+
* module load during SSR, where the locale would diverge and risk hydration
9+
* mismatches. USD is the default display currency until per-column currency
10+
* config exists.
1911
*/
12+
let currencyFormatter: Intl.NumberFormat | undefined
13+
let percentFormatter: Intl.NumberFormat | undefined
14+
15+
function displayLocale(): string {
16+
return typeof navigator === 'undefined' ? 'en-US' : navigator.language
17+
}
18+
19+
/** Formats a currency cell's numeric value for display in the user's locale. */
2020
export function formatCurrencyDisplay(value: number): string {
21-
return CURRENCY_FORMATTER.format(value)
21+
currencyFormatter ??= new Intl.NumberFormat(displayLocale(), {
22+
style: 'currency',
23+
currency: 'USD',
24+
})
25+
return currencyFormatter.format(value)
2226
}
2327

2428
/** Formats a percent cell's numeric value for display, e.g. `12.5` → `12.5%`. */
2529
export function formatPercentDisplay(value: number): string {
26-
return `${PERCENT_FORMATTER.format(value)}%`
30+
percentFormatter ??= new Intl.NumberFormat(displayLocale(), { maximumFractionDigits: 2 })
31+
return `${percentFormatter.format(value)}%`
2732
}
2833

2934
/**

apps/sim/lib/table/__tests__/validation.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,21 @@ describe('Validation', () => {
386386
expect(result.valid).toBe(false)
387387
expect(result.errors[0]).toContain('must be number')
388388
})
389+
390+
it('should round and clamp rating values to 0..RATING_MAX on coercion', () => {
391+
const data = { score: 9.6, price: 9.6 }
392+
coerceRowValues(data, richSchema)
393+
expect(data.score).toBe(5)
394+
expect(data.price).toBe(9.6)
395+
396+
const low = { score: -3 }
397+
coerceRowValues(low, richSchema)
398+
expect(low.score).toBe(0)
399+
400+
const fractional = { score: '3.4' }
401+
coerceRowValues(fractional, richSchema)
402+
expect(fractional.score).toBe(3)
403+
})
389404
})
390405
})
391406

apps/sim/lib/table/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ export const COLUMN_TYPES = [
137137
'rating',
138138
] as const
139139

140+
/** Ratings range 0..RATING_MAX; `rating` cells render as RATING_MAX stars. */
141+
export const RATING_MAX = 5
142+
140143
/** Storage primitives — the value shapes actually persisted in row data. */
141144
export const STORAGE_COLUMN_TYPES = ['string', 'number', 'boolean', 'date', 'json'] as const
142145

apps/sim/lib/table/validation.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import { userTableRows } from '@sim/db/schema'
77
import { and, eq, or, type SQL, sql } from 'drizzle-orm'
88
import { NextResponse } from 'next/server'
99
import { getColumnId } from './column-keys'
10-
import { COLUMN_TYPES, getColumnStorageType, NAME_PATTERN, TABLE_LIMITS } from './constants'
10+
import {
11+
COLUMN_TYPES,
12+
getColumnStorageType,
13+
NAME_PATTERN,
14+
RATING_MAX,
15+
TABLE_LIMITS,
16+
} from './constants'
1117
import { withSeqscanOff } from './planner'
1218
import type { ColumnDefinition, JsonValue, RowData, TableSchema, ValidationResult } from './types'
1319

@@ -279,15 +285,21 @@ function coerceValueToColumnType(
279285
return { ok: true, value: String(value) }
280286
}
281287
return { ok: false }
282-
case 'number':
283-
if (typeof value === 'number') {
284-
return Number.isFinite(value) ? { ok: true, value } : { ok: false }
288+
case 'number': {
289+
const num =
290+
typeof value === 'number'
291+
? value
292+
: typeof value === 'string' && value.trim() !== ''
293+
? Number(value)
294+
: Number.NaN
295+
if (!Number.isFinite(num)) return { ok: false }
296+
// Ratings normalize to whole stars in 0..RATING_MAX on every write path
297+
// (API, batch, CSV import) so storage always matches what the grid renders.
298+
if (type === 'rating') {
299+
return { ok: true, value: Math.min(RATING_MAX, Math.max(0, Math.round(num))) }
285300
}
286-
if (typeof value === 'string' && value.trim() !== '') {
287-
const parsed = Number(value)
288-
return Number.isFinite(parsed) ? { ok: true, value: parsed } : { ok: false }
289-
}
290-
return { ok: false }
301+
return { ok: true, value: num }
302+
}
291303
case 'boolean':
292304
if (typeof value === 'boolean') return { ok: true, value }
293305
if (typeof value === 'string') {

0 commit comments

Comments
 (0)