Skip to content

Commit 228ba69

Browse files
fix(tables): export select columns as option names, not ids
CSV/JSON export serialized the raw stored value for select cells — an option id (or an array of ids for multi) — so downloads showed opaque `opt_…` ids. Export now resolves ids to option names: CSV comma-joins multi names; JSON resolves values before the id→name key translation. Ids with no matching option (deleted) are dropped.
1 parent 0c465c8 commit 228ba69

3 files changed

Lines changed: 117 additions & 3 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { formatCsvCell, resolveSelectExportValue } from '@/lib/table/export-format'
6+
import type { ColumnDefinition } from '@/lib/table/types'
7+
8+
const singleSelect: ColumnDefinition = {
9+
id: 'col_status',
10+
name: 'status',
11+
type: 'select',
12+
options: [
13+
{ id: 'opt_open', name: 'Open', color: 'green' },
14+
{ id: 'opt_closed', name: 'Closed', color: 'red' },
15+
],
16+
}
17+
18+
const multiSelect: ColumnDefinition = {
19+
id: 'col_tags',
20+
name: 'tags',
21+
type: 'select',
22+
multiple: true,
23+
options: [
24+
{ id: 'opt_a', name: 'Alpha', color: 'blue' },
25+
{ id: 'opt_b', name: 'Beta', color: 'purple' },
26+
],
27+
}
28+
29+
describe('resolveSelectExportValue', () => {
30+
it('maps a single option id to its name', () => {
31+
expect(resolveSelectExportValue(singleSelect, 'opt_open')).toBe('Open')
32+
})
33+
34+
it('maps multi option ids to a names array in order', () => {
35+
expect(resolveSelectExportValue(multiSelect, ['opt_b', 'opt_a'])).toEqual(['Beta', 'Alpha'])
36+
})
37+
38+
it('drops ids with no matching option', () => {
39+
expect(resolveSelectExportValue(multiSelect, ['opt_a', 'gone'])).toEqual(['Alpha'])
40+
})
41+
42+
it('returns null for an empty single value', () => {
43+
expect(resolveSelectExportValue(singleSelect, null)).toBeNull()
44+
})
45+
})
46+
47+
describe('formatCsvCell', () => {
48+
it('renders the option name, not the id', () => {
49+
expect(formatCsvCell(singleSelect, 'opt_closed')).toBe('Closed')
50+
})
51+
52+
it('comma-joins multi option names', () => {
53+
expect(formatCsvCell(multiSelect, ['opt_a', 'opt_b'])).toBe('Alpha, Beta')
54+
})
55+
56+
it('is empty when nothing is selected', () => {
57+
expect(formatCsvCell(singleSelect, null)).toBe('')
58+
expect(formatCsvCell(multiSelect, [])).toBe('')
59+
})
60+
61+
it('falls through to the plain formatter for non-select columns', () => {
62+
const num: ColumnDefinition = { id: 'c', name: 'n', type: 'number' }
63+
expect(formatCsvCell(num, 42)).toBe('42')
64+
})
65+
})

apps/sim/lib/table/export-format.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@
44
* byte-identical files.
55
*/
66

7+
import type { ColumnDefinition } from '@/lib/table/types'
8+
9+
/**
10+
* Maps a `select` cell's stored option id(s) to their display names — exports
11+
* should read the human-readable label, never the internal id. Single columns
12+
* return the option name (or null); multi columns return an array of names.
13+
* Ids with no matching option (deleted) are dropped.
14+
*/
15+
export function resolveSelectExportValue(
16+
column: ColumnDefinition,
17+
value: unknown
18+
): string | string[] | null {
19+
const byId = new Map((column.options ?? []).map((o) => [o.id, o.name]))
20+
const ids = Array.isArray(value)
21+
? value
22+
: typeof value === 'string' && value !== ''
23+
? [value]
24+
: []
25+
const names = ids
26+
.map((id) => (typeof id === 'string' ? byId.get(id) : undefined))
27+
.filter((n): n is string => n != null)
28+
return column.multiple ? names : (names[0] ?? null)
29+
}
30+
731
export function sanitizeExportFilename(name: string): string {
832
const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '')
933
return cleaned || 'table'
@@ -29,6 +53,19 @@ export function formatCsvValue(value: unknown): string {
2953
return String(value)
3054
}
3155

56+
/**
57+
* Serializes one cell for CSV, resolving `select` option ids to their names
58+
* (comma-joined for multi) so the file shows the enum label, not the id.
59+
*/
60+
export function formatCsvCell(column: ColumnDefinition, value: unknown): string {
61+
if (column.type === 'select') {
62+
const resolved = resolveSelectExportValue(column, value)
63+
const text = Array.isArray(resolved) ? resolved.join(', ') : (resolved ?? '')
64+
return neutralizeCsvFormula(text)
65+
}
66+
return formatCsvValue(value)
67+
}
68+
3269
export function toCsvRow(values: string[]): string {
3370
return values.map(escapeCsvField).join(',')
3471
}

apps/sim/lib/table/export-runner.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { generateId } from '@sim/utils/id'
44
import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys'
55
import { appendTableEvent } from '@/lib/table/events'
66
import {
7-
formatCsvValue,
7+
formatCsvCell,
88
neutralizeCsvFormula,
9+
resolveSelectExportValue,
910
sanitizeExportFilename,
1011
toCsvRow,
1112
} from '@/lib/table/export-format'
@@ -59,6 +60,8 @@ export async function runTableExport(payload: TableExportPayload): Promise<void>
5960
if (!table) throw new Error(`Export target table ${tableId} not found`)
6061

6162
const columns = table.schema.columns
63+
// Select cells store option ids; exports resolve them to option names below.
64+
const selectColumns = columns.filter((c) => c.type === 'select')
6265
// Stored row data is id-keyed; CSV headers and JSON keys are display names, so translate
6366
// id → name on the way out (export is a name-friendly boundary).
6467
const nameById = buildNameById(table.schema)
@@ -91,12 +94,21 @@ export async function runTableExport(payload: TableExportPayload): Promise<void>
9194
for (const row of page) {
9295
if (format === 'csv') {
9396
pageChunks.push(
94-
`${toCsvRow(columns.map((c) => formatCsvValue(row.data[getColumnId(c)])))}\n`
97+
`${toCsvRow(columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)])))}\n`
9598
)
9699
} else {
97100
const prefix = firstJsonRow ? '' : ','
98101
firstJsonRow = false
99-
pageChunks.push(prefix + JSON.stringify(rowDataIdToName(row.data, nameById)))
102+
// Resolve select ids → names before the id → name key translation.
103+
let data = row.data
104+
if (selectColumns.length > 0) {
105+
data = { ...data }
106+
for (const c of selectColumns) {
107+
const key = getColumnId(c)
108+
if (key in data) data[key] = resolveSelectExportValue(c, data[key])
109+
}
110+
}
111+
pageChunks.push(prefix + JSON.stringify(rowDataIdToName(data, nameById)))
100112
}
101113
}
102114
await handle.write(pageChunks.join(''))

0 commit comments

Comments
 (0)