Skip to content

Commit aeb5efe

Browse files
committed
refactor(tables): add column type extension points
1 parent 57235de commit aeb5efe

16 files changed

Lines changed: 303 additions & 34 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+
})

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

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
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
@@ -14,6 +14,8 @@ 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,30 @@ 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+
function columnTypeLimitMessage(label: string, maxPerTable: number): string {
37+
return maxPerTable === 1
38+
? `Only one ${label} column allowed per table`
39+
: `Only ${maxPerTable} ${label} columns allowed per table`
40+
}
41+
42+
/** Picker entries with unavailable cardinality-limited types marked as disabled. */
43+
export function columnTypeOptionsForTable(
44+
columns: readonly ColumnDefinition[],
45+
currentColumn?: ColumnDefinition | null
46+
): ColumnTypeOption[] {
47+
return COLUMN_TYPE_OPTIONS.map((option) => {
48+
if (option.type === 'workflow') return option
49+
if (currentColumn?.type === option.type) return option
50+
if (!wouldExceedColumnTypeLimit(columns, option.type, 1)) return option
51+
if (option.maxPerTable === undefined) return option
52+
return {
53+
...option,
54+
disabledReason: columnTypeLimitMessage(option.label, option.maxPerTable),
55+
}
56+
})
57+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ export { ColumnConfigSidebar } from './column-config-sidebar'
33
export {
44
COLUMN_TYPE_OPTIONS,
55
type ColumnTypeOption,
6-
PLAIN_COLUMN_TYPE_OPTIONS,
6+
columnTypeOptionsForTable,
77
type SidebarColumnType,
88
} from './column-types'

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

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,17 @@ import {
1111
DropdownMenuSeparator,
1212
DropdownMenuTrigger,
1313
Plus,
14+
Tooltip,
1415
} from '@sim/emcn'
1516
import { Sparkles } from '@sim/emcn/icons'
1617
import type { ColumnDefinition } from '@/lib/table'
17-
import { COLUMN_TYPE_OPTIONS } from '../column-config-sidebar'
18+
import { type ColumnTypeOption, columnTypeOptionsForTable } from '../column-config-sidebar'
1819

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

2223
interface NewColumnDropdownProps {
24+
columns: readonly ColumnDefinition[]
2325
/** `'header'` renders the page-header trigger (subtle Button); `'inline-header'` renders
2426
* the in-table column-header `<th>` trigger. Same dropdown content either way. */
2527
trigger: 'header' | 'inline-header'
@@ -37,12 +39,49 @@ interface NewColumnDropdownProps {
3739
onBlocked: () => void
3840
}
3941

42+
interface ColumnTypeMenuItemProps {
43+
option: ColumnTypeOption
44+
onSelect: () => void
45+
}
46+
47+
function ColumnTypeMenuItem({ option, onSelect }: ColumnTypeMenuItemProps) {
48+
const Icon = option.icon
49+
const item = (
50+
<DropdownMenuItem
51+
aria-disabled={option.disabledReason ? true : undefined}
52+
className={
53+
option.disabledReason ? 'cursor-not-allowed opacity-50 focus:bg-transparent' : undefined
54+
}
55+
onSelect={(event) => {
56+
if (option.disabledReason) {
57+
event.preventDefault()
58+
return
59+
}
60+
onSelect()
61+
}}
62+
>
63+
<Icon className='size-[14px] text-[var(--text-icon)]' />
64+
{option.label}
65+
</DropdownMenuItem>
66+
)
67+
68+
if (!option.disabledReason) return item
69+
70+
return (
71+
<Tooltip.Root>
72+
<Tooltip.Trigger asChild>{item}</Tooltip.Trigger>
73+
<Tooltip.Content>{option.disabledReason}</Tooltip.Content>
74+
</Tooltip.Root>
75+
)
76+
}
77+
4078
/**
4179
* "+ New column" dropdown — the single entry point for creating a column.
4280
* Lists every column type plus "Workflow" and "Enrichments"; picking a type
4381
* opens the right sidebar pre-seeded.
4482
*/
4583
export function NewColumnDropdown({
84+
columns,
4685
trigger,
4786
disabled,
4887
onPickType,
@@ -98,18 +137,12 @@ export function NewColumnDropdown({
98137
</DropdownMenuItem>
99138
<DropdownMenuSeparator />
100139
</>
101-
{COLUMN_TYPE_OPTIONS.map((option) => {
102-
const Icon = option.icon
140+
{columnTypeOptionsForTable(columns).map((option) => {
103141
const onSelect =
104142
option.type === 'workflow'
105143
? onPickWorkflow
106144
: () => onPickType(option.type as ColumnDefinition['type'])
107-
return (
108-
<DropdownMenuItem key={option.type} onSelect={onSelect}>
109-
<Icon className='size-[14px] text-[var(--text-icon)]' />
110-
{option.label}
111-
</DropdownMenuItem>
112-
)
145+
return <ColumnTypeMenuItem key={option.type} option={option} onSelect={onSelect} />
113146
})}
114147
</DropdownMenuContent>
115148
</DropdownMenu>

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
@@ -4854,6 +4854,7 @@ export function TableGrid({
48544854
})}
48554855
{userPermissions.canEdit && (
48564856
<NewColumnDropdown
4857+
columns={columns}
48574858
trigger='inline-header'
48584859
disabled={addColumnMutation.isPending}
48594860
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
<NewColumnDropdown
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 { coerceValue } from '@/lib/table/import'
12+
import type { ColumnDefinition } from '@/lib/table/types'
13+
14+
const definition = COLUMN_TYPE_REGISTRY.string
15+
const originalMaxPerTable = definition.maxPerTable
16+
const originalCoerceImport = definition.coerceImport
17+
const originalValueForConversion = definition.valueForConversion
18+
19+
function restoreOptionalProperty(
20+
key: 'maxPerTable' | 'coerceImport' | 'valueForConversion',
21+
value: unknown
22+
) {
23+
if (value === undefined) {
24+
Reflect.deleteProperty(definition, key)
25+
return
26+
}
27+
Object.assign(definition, { [key]: value })
28+
}
29+
30+
afterEach(() => {
31+
restoreOptionalProperty('maxPerTable', originalMaxPerTable)
32+
restoreOptionalProperty('coerceImport', originalCoerceImport)
33+
restoreOptionalProperty('valueForConversion', originalValueForConversion)
34+
})
35+
36+
describe('column type extension points', () => {
37+
it('enforces registry-declared per-table limits', () => {
38+
Object.assign(definition, { maxPerTable: 1 })
39+
const columns: ColumnDefinition[] = [
40+
{ name: 'first', type: 'string' },
41+
{ name: 'second', type: 'string' },
42+
]
43+
44+
expect(wouldExceedColumnTypeLimit(columns.slice(0, 1), 'string', 1)).toBe(true)
45+
expect(validateColumnTypeLimits(columns)).toEqual([
46+
`A table can have at most 1 ${definition.label} column`,
47+
])
48+
})
49+
50+
it('lets the source type normalize a value before conversion', () => {
51+
Object.assign(definition, {
52+
valueForConversion: (_value: unknown, target: ColumnDefinition) =>
53+
target.type === 'number' ? 42 : 'unchanged',
54+
})
55+
56+
expect(
57+
valueForTypeConversion(
58+
'stored-value',
59+
{ name: 'source', type: 'string' },
60+
{ name: 'target', type: 'number' }
61+
)
62+
).toBe(42)
63+
expect(
64+
valueForTypeConversion(
65+
'stored-value',
66+
{ name: 'source', type: 'number' },
67+
{ name: 'target', type: 'string' }
68+
)
69+
).toBe('stored-value')
70+
})
71+
72+
it('lets a type own CSV import coercion', () => {
73+
Object.assign(definition, {
74+
coerceImport: (value: unknown) => `imported:${String(value)}`,
75+
})
76+
77+
expect(coerceValue('raw', 'string')).toBe('imported:raw')
78+
})
79+
})

apps/sim/lib/table/column-types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export * from '@/lib/table/column-types/registry'
1414
export type {
1515
CoerceResult,
1616
ColumnCellEditor,
17+
ColumnImportCoerceOptions,
1718
ColumnType,
1819
ColumnTypeDefinition,
1920
TypeSpecificColumnKey,

0 commit comments

Comments
 (0)