Skip to content

Commit 0efab28

Browse files
fix(tables): let a multiselect round-trip through text
Converting multiselect to text flattens cells to `Alpha, Beta`, but converting back read that as one unknown option and rejected the change. Both the compatibility check and the cell migration now split a comma string the way the write path already did, through one shared helper.
1 parent 6f860fc commit 0efab28

3 files changed

Lines changed: 61 additions & 15 deletions

File tree

apps/sim/lib/table/__tests__/column-conversion.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,20 @@ describe('isValueCompatibleWithType — select cardinality', () => {
6868
it('rejects a value that is not a declared option', () => {
6969
expect(isValueCompatibleWithType('gone', 'select', OPTIONS, false)).toBe(false)
7070
})
71+
72+
it('round-trips a multiselect through text', () => {
73+
// multiselect → string flattens to `Alpha, Beta`; converting back must read
74+
// that the same way the write-path coercion does, not as one unknown option.
75+
const flattened = selectValueForConversion(multi, ['opt_a', 'opt_b'])
76+
expect(flattened).toBe('Alpha, Beta')
77+
expect(isValueCompatibleWithType(flattened, 'select', OPTIONS, true)).toBe(true)
78+
// A single-select target genuinely can't hold both.
79+
expect(isValueCompatibleWithType(flattened, 'select', OPTIONS, false)).toBe(false)
80+
})
81+
82+
it('still rejects a comma string holding an undeclared option', () => {
83+
expect(isValueCompatibleWithType('Alpha, Gone', 'select', OPTIONS, true)).toBe(false)
84+
})
7185
})
7286

7387
describe('isValueCompatibleWithType — string target', () => {

apps/sim/lib/table/columns/service.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ import type {
3434
UpdateColumnOptionsData,
3535
UpdateColumnTypeData,
3636
} from '@/lib/table/types'
37-
import { resolveSelectOptionId, validateColumnDefinition } from '@/lib/table/validation'
37+
import {
38+
resolveSelectOptionId,
39+
splitMultiSelectInput,
40+
validateColumnDefinition,
41+
} from '@/lib/table/validation'
3842
import { assertValidSchema, stripGroupDeps } from '@/lib/table/workflow-columns'
3943

4044
const logger = createLogger('TableColumnService')
@@ -881,11 +885,25 @@ async function migrateCellsToSelectIds(
881885
)
882886

883887
if (multiple) {
888+
// A string cell reaching a multi target is either one option name or the
889+
// comma-joined form a multiselect converts to text as. Try the whole string
890+
// first so an option whose own name contains a comma still wins, then split
891+
// — mirroring `splitMultiSelectInput` on the write path, including its
892+
// first-occurrence dedup.
884893
await trx.execute(
885894
sql`UPDATE ${userTableRows}
886895
SET data = jsonb_set(data, ARRAY[${columnKey}::text],
887896
CASE WHEN data->>${columnKey}::text = '' THEN '[]'::jsonb
888-
ELSE jsonb_build_array(COALESCE(${idByRef}::jsonb -> (data->>${columnKey}::text), ${idByRef}::jsonb -> lower(data->>${columnKey}::text), data->${columnKey}::text))
897+
WHEN COALESCE(${idByRef}::jsonb -> (data->>${columnKey}::text), ${idByRef}::jsonb -> lower(data->>${columnKey}::text)) IS NOT NULL
898+
THEN jsonb_build_array(COALESCE(${idByRef}::jsonb -> (data->>${columnKey}::text), ${idByRef}::jsonb -> lower(data->>${columnKey}::text)))
899+
ELSE COALESCE((
900+
SELECT jsonb_agg(v ORDER BY ord) FROM (
901+
SELECT COALESCE(${idByRef}::jsonb -> btrim(part), ${idByRef}::jsonb -> lower(btrim(part)), to_jsonb(btrim(part))) AS v,
902+
min(o) AS ord
903+
FROM unnest(string_to_array(data->>${columnKey}::text, ',')) WITH ORDINALITY AS u(part, o)
904+
WHERE btrim(part) <> ''
905+
GROUP BY 1
906+
) d), '[]'::jsonb)
889907
END)
890908
WHERE table_id = ${tableId}
891909
AND jsonb_typeof(data->${columnKey}::text) = 'string'`
@@ -1004,7 +1022,15 @@ export function isValueCompatibleWithType(
10041022
case 'select': {
10051023
// A cleared select cell is written as '' — still convertible.
10061024
if (value === '') return true
1007-
const parts = Array.isArray(value) ? value : [value]
1025+
// Read the value exactly as the write-path coercion will. A multi target
1026+
// splits a comma-delimited string, so a multiselect → text → multiselect
1027+
// round-trip (text holding this feature's own `Bug, Docs` export shape)
1028+
// stays convertible instead of being rejected as one unknown option.
1029+
const parts = targetMultiple
1030+
? splitMultiSelectInput(value as JsonValue)
1031+
: Array.isArray(value)
1032+
? value
1033+
: [value]
10081034
// A single-select target can't hold several options. `updateColumnOptions`
10091035
// blocks the same transition; without this the next coerce would silently
10101036
// keep only the first id.

apps/sim/lib/table/validation.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,23 @@ export function resolveSelectOptionId(value: JsonValue, options: SelectOption[])
306306
return byName ? byName.id : null
307307
}
308308

309+
/**
310+
* Splits a raw value into the parts a multi-select cell should resolve. A cell
311+
* may arrive as an array (canonical) or as a single comma-delimited string —
312+
* the shape a multi cell exports, copies, and converts to text as — so both the
313+
* write-path coercion and the column-conversion compatibility check read it
314+
* through here rather than each deciding for itself. Option names that
315+
* themselves contain commas are an accepted ambiguity.
316+
*/
317+
export function splitMultiSelectInput(value: JsonValue): JsonValue[] {
318+
if (Array.isArray(value)) return value
319+
if (typeof value !== 'string') return [value]
320+
return value
321+
.split(',')
322+
.map((part) => part.trim())
323+
.filter((part) => part !== '')
324+
}
325+
309326
/**
310327
* Attempts to coerce a non-null value to a column's declared type. Returns the
311328
* coerced value when the value already matches or can be converted without
@@ -357,18 +374,7 @@ function coerceValueToColumnType(
357374
case 'select': {
358375
const options = column.options ?? []
359376
if (column.multiple) {
360-
// A multi-select cell may arrive as an array (canonical) or a single
361-
// comma-delimited string (CSV import / clipboard paste of the read
362-
// format) — split the latter so each label resolves. Option names that
363-
// themselves contain commas are an accepted ambiguity here.
364-
const raw = Array.isArray(value)
365-
? value
366-
: typeof value === 'string'
367-
? value
368-
.split(',')
369-
.map((part) => part.trim())
370-
.filter((part) => part !== '')
371-
: [value]
377+
const raw = splitMultiSelectInput(value)
372378
const ids: string[] = []
373379
for (const entry of raw) {
374380
const id = resolveSelectOptionId(entry, options)

0 commit comments

Comments
 (0)