Skip to content

Commit 029ded6

Browse files
authored
refactor(tables): add column type extension points (#7119)
* refactor(tables): add column type extension points * Address PR review feedback (#7119) - preserve explicit nulls from source-owned conversion normalization - normalize hooked values before select migration - cover null and select conversion rewrites * refactor(tables): short-circuit unlimited column types * refactor(tables): keep CSV coercion in import switch * test(tables): cover rebased column dropdown
1 parent f607c01 commit 029ded6

17 files changed

Lines changed: 350 additions & 40 deletions

File tree

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields'
1818
import { useAddTableColumn, useUpdateColumn } from '@/hooks/queries/tables'
1919
import { SelectOptionsEditor } from '../select-field'
20-
import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types'
20+
import { columnTypeOptionsForTable } from './column-types'
2121

2222
/** Whether a column type carries an option set. */
2323
function isSelectType(type: ColumnDefinition['type']): boolean {
@@ -52,6 +52,7 @@ interface ColumnConfigSidebarProps {
5252
onClose: () => void
5353
/** Existing column record for `mode: 'edit'`; ignored otherwise. */
5454
existingColumn: ColumnDefinition | null
55+
allColumns: readonly ColumnDefinition[]
5556
workspaceId: string
5657
tableId: string
5758
/** Notify parent of a rename so it can rewrite local `columnOrder` /
@@ -102,6 +103,7 @@ function ColumnConfigBody({
102103
config,
103104
onClose,
104105
existingColumn,
106+
allColumns,
105107
workspaceId,
106108
tableId,
107109
onColumnRename,
@@ -274,11 +276,14 @@ function ColumnConfigBody({
274276
<div className='flex flex-col gap-[9.5px]'>
275277
<RequiredLabel>Type</RequiredLabel>
276278
<ChipCombobox
277-
options={PLAIN_COLUMN_TYPE_OPTIONS.map((o) => ({
278-
label: o.label,
279-
value: o.type,
280-
icon: o.icon,
281-
}))}
279+
options={columnTypeOptionsForTable(allColumns, existingColumn)
280+
.filter((option) => option.type !== 'workflow')
281+
.map((option) => ({
282+
label: option.label,
283+
value: option.type,
284+
icon: option.icon,
285+
disabled: option.disabledReason !== undefined,
286+
}))}
282287
value={typeInput}
283288
onChange={(v) => setTypeInput(v as ColumnDefinition['type'])}
284289
placeholder='Select type'
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, describe, expect, it } from 'vitest'
5+
import { COLUMN_TYPE_REGISTRY } from '@/lib/table/column-types'
6+
import {
7+
COLUMN_TYPE_OPTIONS,
8+
columnTypeOptionsForTable,
9+
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types'
10+
11+
const option = COLUMN_TYPE_OPTIONS.find((candidate) => candidate.type === 'string')
12+
if (!option) throw new Error('String column type option is missing')
13+
const originalMaxPerTable = option.maxPerTable
14+
const definition = COLUMN_TYPE_REGISTRY.string
15+
const originalDefinitionMaxPerTable = definition.maxPerTable
16+
17+
afterEach(() => {
18+
if (originalMaxPerTable === undefined) {
19+
Reflect.deleteProperty(option, 'maxPerTable')
20+
} else {
21+
option.maxPerTable = originalMaxPerTable
22+
}
23+
24+
if (originalDefinitionMaxPerTable === undefined) {
25+
Reflect.deleteProperty(definition, 'maxPerTable')
26+
} else {
27+
Object.assign(definition, { maxPerTable: originalDefinitionMaxPerTable })
28+
}
29+
})
30+
31+
describe('column type picker limits', () => {
32+
it('keeps a limited type visible but disables it once the limit is reached', () => {
33+
option.maxPerTable = 1
34+
Object.assign(definition, { maxPerTable: 1 })
35+
36+
const result = columnTypeOptionsForTable([{ name: 'first', type: 'string' }])
37+
const stringOption = result.find((candidate) => candidate.type === 'string')
38+
39+
expect(stringOption?.disabledReason).toBe('Only one Text column allowed per table')
40+
})
41+
42+
it('keeps the current type selectable while editing its existing column', () => {
43+
option.maxPerTable = 1
44+
Object.assign(definition, { maxPerTable: 1 })
45+
const currentColumn = { name: 'first', type: 'string' } as const
46+
47+
const result = columnTypeOptionsForTable([currentColumn], currentColumn)
48+
const stringOption = result.find((candidate) => candidate.type === 'string')
49+
50+
expect(stringOption?.disabledReason).toBeUndefined()
51+
})
52+
})
Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import type React from 'react'
22
import { PlayOutline } from '@sim/emcn/icons'
33
import type { ColumnDefinition } from '@/lib/table'
4-
import { ALL_COLUMN_TYPES } from '@/lib/table/column-types'
4+
import { ALL_COLUMN_TYPES, wouldExceedColumnTypeLimit } from '@/lib/table/column-types'
55

66
/**
77
* UI-only column type. `'workflow'` is the virtual entry users pick from the
88
* "+ New column" dropdown to spawn a workflow group; the resulting columns are
99
* stored as scalar types under the hood (none carry `'workflow'`).
1010
*/
11-
type SidebarColumnType = ColumnDefinition['type'] | 'workflow'
11+
export type SidebarColumnType = ColumnDefinition['type'] | 'workflow'
1212

13-
interface ColumnTypeOption {
13+
export interface ColumnTypeOption {
1414
type: SidebarColumnType
1515
label: string
1616
icon: React.ComponentType<{ className?: string }>
17+
maxPerTable?: number
18+
disabledReason?: string
1719
}
1820

1921
/**
@@ -26,9 +28,35 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [
2628
type: definition.id,
2729
label: definition.label,
2830
icon: definition.icon,
31+
maxPerTable: definition.maxPerTable,
2932
})),
3033
{ type: 'workflow', label: 'Workflow', icon: PlayOutline },
3134
]
3235

33-
/** Plain column types (no workflow). Used by `<ColumnConfigSidebar>`'s type combobox in edit mode. */
34-
export const PLAIN_COLUMN_TYPE_OPTIONS = COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow')
36+
/** Plain column types (no workflow). Used by the column type combobox in edit mode. */
37+
export const PLAIN_COLUMN_TYPE_OPTIONS = COLUMN_TYPE_OPTIONS.filter(
38+
(option) => option.type !== 'workflow'
39+
)
40+
41+
function columnTypeLimitMessage(label: string, maxPerTable: number): string {
42+
return maxPerTable === 1
43+
? `Only one ${label} column allowed per table`
44+
: `Only ${maxPerTable} ${label} columns allowed per table`
45+
}
46+
47+
/** Picker entries with unavailable cardinality-limited types marked as disabled. */
48+
export function columnTypeOptionsForTable(
49+
columns: readonly ColumnDefinition[],
50+
currentColumn?: ColumnDefinition | null
51+
): ColumnTypeOption[] {
52+
return COLUMN_TYPE_OPTIONS.map((option) => {
53+
if (option.type === 'workflow') return option
54+
if (currentColumn?.type === option.type) return option
55+
if (option.maxPerTable === undefined) return option
56+
if (!wouldExceedColumnTypeLimit(columns, option.type, 1)) return option
57+
return {
58+
...option,
59+
disabledReason: columnTypeLimitMessage(option.label, option.maxPerTable),
60+
}
61+
})
62+
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
export type { ColumnConfig } from './column-config-sidebar'
22
export { ColumnConfigSidebar } from './column-config-sidebar'
3-
export { COLUMN_TYPE_OPTIONS, PLAIN_COLUMN_TYPE_OPTIONS } from './column-types'
3+
export {
4+
COLUMN_TYPE_OPTIONS,
5+
type ColumnTypeOption,
6+
columnTypeOptionsForTable,
7+
PLAIN_COLUMN_TYPE_OPTIONS,
8+
type SidebarColumnType,
9+
} from './column-types'

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe('ColumnDropdown', () => {
2929
act(() => {
3030
root.render(
3131
<ColumnDropdown
32+
columns={[]}
3233
trigger='header'
3334
disabled={false}
3435
onPickType={vi.fn()}

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

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,17 @@ import {
1010
DropdownMenuItem,
1111
DropdownMenuTrigger,
1212
Plus,
13+
Tooltip,
1314
} from '@sim/emcn'
1415
import { Sparkles } from '@sim/emcn/icons'
1516
import type { ColumnDefinition } from '@/lib/table'
16-
import { COLUMN_TYPE_OPTIONS } from '../column-config-sidebar'
17+
import { type ColumnTypeOption, columnTypeOptionsForTable } from '../column-config-sidebar'
1718

1819
const CELL_HEADER =
1920
'border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[7px] text-left align-middle'
2021

2122
interface ColumnDropdownProps {
23+
columns: readonly ColumnDefinition[]
2224
/** `'header'` renders the page-header trigger (subtle Button); `'inline-header'` renders
2325
* the in-table column-header `<th>` trigger. Same dropdown content either way. */
2426
trigger: 'header' | 'inline-header'
@@ -36,12 +38,49 @@ interface ColumnDropdownProps {
3638
onBlocked: () => void
3739
}
3840

41+
interface ColumnTypeMenuItemProps {
42+
option: ColumnTypeOption
43+
onSelect: () => void
44+
}
45+
46+
function ColumnTypeMenuItem({ option, onSelect }: ColumnTypeMenuItemProps) {
47+
const Icon = option.icon
48+
const item = (
49+
<DropdownMenuItem
50+
aria-disabled={option.disabledReason ? true : undefined}
51+
className={
52+
option.disabledReason ? 'cursor-not-allowed opacity-50 focus:bg-transparent' : undefined
53+
}
54+
onSelect={(event) => {
55+
if (option.disabledReason) {
56+
event.preventDefault()
57+
return
58+
}
59+
onSelect()
60+
}}
61+
>
62+
<Icon className='size-[14px] text-[var(--text-icon)]' />
63+
{option.label}
64+
</DropdownMenuItem>
65+
)
66+
67+
if (!option.disabledReason) return item
68+
69+
return (
70+
<Tooltip.Root>
71+
<Tooltip.Trigger asChild>{item}</Tooltip.Trigger>
72+
<Tooltip.Content>{option.disabledReason}</Tooltip.Content>
73+
</Tooltip.Root>
74+
)
75+
}
76+
3977
/**
4078
* "+ New column" dropdown — the single entry point for creating a column.
4179
* Lists every column type plus "Workflow" and "Enrichments"; picking a type
4280
* opens the right sidebar pre-seeded.
4381
*/
4482
export function ColumnDropdown({
83+
columns,
4584
trigger,
4685
disabled,
4786
onPickType,
@@ -86,18 +125,12 @@ export function ColumnDropdown({
86125
<DropdownMenu>
87126
<DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>
88127
<DropdownMenuContent align='start' side='bottom' sideOffset={4}>
89-
{COLUMN_TYPE_OPTIONS.map((option) => {
90-
const Icon = option.icon
128+
{columnTypeOptionsForTable(columns).map((option) => {
91129
const onSelect =
92130
option.type === 'workflow'
93131
? onPickWorkflow
94132
: () => onPickType(option.type as ColumnDefinition['type'])
95-
return (
96-
<DropdownMenuItem key={option.type} onSelect={onSelect}>
97-
<Icon className='size-[14px] text-[var(--text-icon)]' />
98-
{option.label}
99-
</DropdownMenuItem>
100-
)
133+
return <ColumnTypeMenuItem key={option.type} option={option} onSelect={onSelect} />
101134
})}
102135
<DropdownMenuItem onSelect={onPickEnrichment}>
103136
<Sparkles className='size-[14px] text-[var(--text-icon)]' />

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4864,6 +4864,7 @@ export function TableGrid({
48644864
})}
48654865
{userPermissions.canEdit && (
48664866
<ColumnDropdown
4867+
columns={columns}
48674868
trigger='inline-header'
48684869
disabled={addColumnMutation.isPending}
48694870
blocked={!canMutateSchema}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1373,6 +1373,7 @@ export function Table({
13731373
const canMutateSchema = userPermissions.canEdit && !tableData?.locks.schemaLocked
13741374
const createTrigger = userPermissions.canEdit ? (
13751375
<ColumnDropdown
1376+
columns={columns}
13761377
trigger='header'
13771378
disabled={false}
13781379
blocked={!canMutateSchema}
@@ -1645,6 +1646,7 @@ export function Table({
16451646
<ColumnConfigSidebar
16461647
config={columnConfig}
16471648
onClose={onCloseSlideout}
1649+
allColumns={columns}
16481650
existingColumn={
16491651
columnConfig?.mode === 'edit'
16501652
? (columns.find((c) => getColumnId(c) === columnConfig.columnName) ?? null)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, describe, expect, it } from 'vitest'
5+
import {
6+
COLUMN_TYPE_REGISTRY,
7+
validateColumnTypeLimits,
8+
valueForTypeConversion,
9+
wouldExceedColumnTypeLimit,
10+
} from '@/lib/table/column-types'
11+
import type { ColumnDefinition } from '@/lib/table/types'
12+
13+
const definition = COLUMN_TYPE_REGISTRY.string
14+
const originalMaxPerTable = definition.maxPerTable
15+
const originalValueForConversion = definition.valueForConversion
16+
17+
function restoreOptionalProperty(key: 'maxPerTable' | 'valueForConversion', value: unknown) {
18+
if (value === undefined) {
19+
Reflect.deleteProperty(definition, key)
20+
return
21+
}
22+
Object.assign(definition, { [key]: value })
23+
}
24+
25+
afterEach(() => {
26+
restoreOptionalProperty('maxPerTable', originalMaxPerTable)
27+
restoreOptionalProperty('valueForConversion', originalValueForConversion)
28+
})
29+
30+
describe('column type extension points', () => {
31+
it('enforces registry-declared per-table limits', () => {
32+
Object.assign(definition, { maxPerTable: 1 })
33+
const columns: ColumnDefinition[] = [
34+
{ name: 'first', type: 'string' },
35+
{ name: 'second', type: 'string' },
36+
]
37+
38+
expect(wouldExceedColumnTypeLimit(columns.slice(0, 1), 'string', 1)).toBe(true)
39+
expect(validateColumnTypeLimits(columns)).toEqual([
40+
`A table can have at most 1 ${definition.label} column`,
41+
])
42+
})
43+
44+
it('lets the source type normalize a value before conversion', () => {
45+
Object.assign(definition, {
46+
valueForConversion: (_value: unknown, target: ColumnDefinition) =>
47+
target.type === 'number' ? 42 : 'unchanged',
48+
})
49+
50+
expect(
51+
valueForTypeConversion(
52+
'stored-value',
53+
{ name: 'source', type: 'string' },
54+
{ name: 'target', type: 'number' }
55+
)
56+
).toBe(42)
57+
expect(
58+
valueForTypeConversion(
59+
'stored-value',
60+
{ name: 'source', type: 'number' },
61+
{ name: 'target', type: 'string' }
62+
)
63+
).toBe('stored-value')
64+
})
65+
66+
it('preserves an intentional null from source normalization', () => {
67+
Object.assign(definition, {
68+
valueForConversion: () => null,
69+
})
70+
71+
expect(
72+
valueForTypeConversion(
73+
'stored-value',
74+
{ name: 'source', type: 'string' },
75+
{ name: 'target', type: 'number' }
76+
)
77+
).toBeNull()
78+
})
79+
})

0 commit comments

Comments
 (0)