Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields'
import { useAddTableColumn, useUpdateColumn } from '@/hooks/queries/tables'
import { SelectOptionsEditor } from '../select-field'
import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types'
import { columnTypeOptionsForTable } from './column-types'

/** Whether a column type carries an option set. */
function isSelectType(type: ColumnDefinition['type']): boolean {
Expand Down Expand Up @@ -52,6 +52,7 @@ interface ColumnConfigSidebarProps {
onClose: () => void
/** Existing column record for `mode: 'edit'`; ignored otherwise. */
existingColumn: ColumnDefinition | null
allColumns: readonly ColumnDefinition[]
workspaceId: string
tableId: string
/** Notify parent of a rename so it can rewrite local `columnOrder` /
Expand Down Expand Up @@ -102,6 +103,7 @@ function ColumnConfigBody({
config,
onClose,
existingColumn,
allColumns,
workspaceId,
tableId,
onColumnRename,
Expand Down Expand Up @@ -274,11 +276,14 @@ function ColumnConfigBody({
<div className='flex flex-col gap-[9.5px]'>
<RequiredLabel>Type</RequiredLabel>
<ChipCombobox
options={PLAIN_COLUMN_TYPE_OPTIONS.map((o) => ({
label: o.label,
value: o.type,
icon: o.icon,
}))}
options={columnTypeOptionsForTable(allColumns, existingColumn)
.filter((option) => option.type !== 'workflow')
.map((option) => ({
label: option.label,
value: option.type,
icon: option.icon,
disabled: option.disabledReason !== undefined,
}))}
value={typeInput}
onChange={(v) => setTypeInput(v as ColumnDefinition['type'])}
placeholder='Select type'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* @vitest-environment node
*/
import { afterEach, describe, expect, it } from 'vitest'
import { COLUMN_TYPE_REGISTRY } from '@/lib/table/column-types'
import {
COLUMN_TYPE_OPTIONS,
columnTypeOptionsForTable,
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types'

const option = COLUMN_TYPE_OPTIONS.find((candidate) => candidate.type === 'string')
if (!option) throw new Error('String column type option is missing')
const originalMaxPerTable = option.maxPerTable
const definition = COLUMN_TYPE_REGISTRY.string
const originalDefinitionMaxPerTable = definition.maxPerTable

afterEach(() => {
if (originalMaxPerTable === undefined) {
Reflect.deleteProperty(option, 'maxPerTable')
} else {
option.maxPerTable = originalMaxPerTable
}

if (originalDefinitionMaxPerTable === undefined) {
Reflect.deleteProperty(definition, 'maxPerTable')
} else {
Object.assign(definition, { maxPerTable: originalDefinitionMaxPerTable })
}
})

describe('column type picker limits', () => {
it('keeps a limited type visible but disables it once the limit is reached', () => {
option.maxPerTable = 1
Object.assign(definition, { maxPerTable: 1 })

const result = columnTypeOptionsForTable([{ name: 'first', type: 'string' }])
const stringOption = result.find((candidate) => candidate.type === 'string')

expect(stringOption?.disabledReason).toBe('Only one Text column allowed per table')
})

it('keeps the current type selectable while editing its existing column', () => {
option.maxPerTable = 1
Object.assign(definition, { maxPerTable: 1 })
const currentColumn = { name: 'first', type: 'string' } as const

const result = columnTypeOptionsForTable([currentColumn], currentColumn)
const stringOption = result.find((candidate) => candidate.type === 'string')

expect(stringOption?.disabledReason).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type React from 'react'
import { PlayOutline } from '@sim/emcn/icons'
import type { ColumnDefinition } from '@/lib/table'
import { ALL_COLUMN_TYPES } from '@/lib/table/column-types'
import { ALL_COLUMN_TYPES, wouldExceedColumnTypeLimit } from '@/lib/table/column-types'

/**
* UI-only column type. `'workflow'` is the virtual entry users pick from the
Expand All @@ -14,6 +14,8 @@ export interface ColumnTypeOption {
type: SidebarColumnType
label: string
icon: React.ComponentType<{ className?: string }>
maxPerTable?: number
disabledReason?: string
}

/**
Expand All @@ -26,9 +28,30 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [
type: definition.id,
label: definition.label,
icon: definition.icon,
maxPerTable: definition.maxPerTable,
})),
{ type: 'workflow', label: 'Workflow', icon: PlayOutline },
]

/** Plain column types (no workflow). Used by `<ColumnConfigSidebar>`'s type combobox in edit mode. */
export const PLAIN_COLUMN_TYPE_OPTIONS = COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow')
function columnTypeLimitMessage(label: string, maxPerTable: number): string {
return maxPerTable === 1
? `Only one ${label} column allowed per table`
: `Only ${maxPerTable} ${label} columns allowed per table`
}

/** Picker entries with unavailable cardinality-limited types marked as disabled. */
export function columnTypeOptionsForTable(
columns: readonly ColumnDefinition[],
currentColumn?: ColumnDefinition | null
): ColumnTypeOption[] {
return COLUMN_TYPE_OPTIONS.map((option) => {
if (option.type === 'workflow') return option
if (currentColumn?.type === option.type) return option
if (option.maxPerTable === undefined) return option
if (!wouldExceedColumnTypeLimit(columns, option.type, 1)) return option
return {
...option,
disabledReason: columnTypeLimitMessage(option.label, option.maxPerTable),
}
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ export { ColumnConfigSidebar } from './column-config-sidebar'
export {
COLUMN_TYPE_OPTIONS,
type ColumnTypeOption,
PLAIN_COLUMN_TYPE_OPTIONS,
columnTypeOptionsForTable,
type SidebarColumnType,
} from './column-types'
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
Plus,
Tooltip,
} from '@sim/emcn'
import { Sparkles } from '@sim/emcn/icons'
import type { ColumnDefinition } from '@/lib/table'
import { COLUMN_TYPE_OPTIONS } from '../column-config-sidebar'
import { type ColumnTypeOption, columnTypeOptionsForTable } from '../column-config-sidebar'

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

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

interface ColumnTypeMenuItemProps {
option: ColumnTypeOption
onSelect: () => void
}

function ColumnTypeMenuItem({ option, onSelect }: ColumnTypeMenuItemProps) {
const Icon = option.icon
const item = (
<DropdownMenuItem
aria-disabled={option.disabledReason ? true : undefined}
className={
option.disabledReason ? 'cursor-not-allowed opacity-50 focus:bg-transparent' : undefined
}
onSelect={(event) => {
if (option.disabledReason) {
event.preventDefault()
return
}
onSelect()
}}
>
<Icon className='size-[14px] text-[var(--text-icon)]' />
{option.label}
</DropdownMenuItem>
)

if (!option.disabledReason) return item

return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{item}</Tooltip.Trigger>
<Tooltip.Content>{option.disabledReason}</Tooltip.Content>
</Tooltip.Root>
)
}

/**
* "+ New column" dropdown — the single entry point for creating a column.
* Lists every column type plus "Workflow" and "Enrichments"; picking a type
* opens the right sidebar pre-seeded.
*/
export function NewColumnDropdown({
columns,
trigger,
disabled,
onPickType,
Expand Down Expand Up @@ -98,18 +137,12 @@ export function NewColumnDropdown({
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
{COLUMN_TYPE_OPTIONS.map((option) => {
const Icon = option.icon
{columnTypeOptionsForTable(columns).map((option) => {
const onSelect =
option.type === 'workflow'
? onPickWorkflow
: () => onPickType(option.type as ColumnDefinition['type'])
return (
<DropdownMenuItem key={option.type} onSelect={onSelect}>
<Icon className='size-[14px] text-[var(--text-icon)]' />
{option.label}
</DropdownMenuItem>
)
return <ColumnTypeMenuItem key={option.type} option={option} onSelect={onSelect} />
})}
</DropdownMenuContent>
</DropdownMenu>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4833,6 +4833,7 @@ export function TableGrid({
})}
{userPermissions.canEdit && (
<NewColumnDropdown
columns={columns}
trigger='inline-header'
disabled={addColumnMutation.isPending}
blocked={!canMutateSchema}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,7 @@ export function Table({
const canMutateSchema = userPermissions.canEdit && !tableData?.locks.schemaLocked
const createTrigger = userPermissions.canEdit ? (
<NewColumnDropdown
columns={columns}
trigger='header'
disabled={false}
blocked={!canMutateSchema}
Expand Down Expand Up @@ -1645,6 +1646,7 @@ export function Table({
<ColumnConfigSidebar
config={columnConfig}
onClose={onCloseSlideout}
allColumns={columns}
existingColumn={
columnConfig?.mode === 'edit'
? (columns.find((c) => getColumnId(c) === columnConfig.columnName) ?? null)
Expand Down
79 changes: 79 additions & 0 deletions apps/sim/lib/table/column-types/extension-points.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* @vitest-environment node
*/
import { afterEach, describe, expect, it } from 'vitest'
import {
COLUMN_TYPE_REGISTRY,
validateColumnTypeLimits,
valueForTypeConversion,
wouldExceedColumnTypeLimit,
} from '@/lib/table/column-types'
import type { ColumnDefinition } from '@/lib/table/types'

const definition = COLUMN_TYPE_REGISTRY.string
const originalMaxPerTable = definition.maxPerTable
const originalValueForConversion = definition.valueForConversion

function restoreOptionalProperty(key: 'maxPerTable' | 'valueForConversion', value: unknown) {
if (value === undefined) {
Reflect.deleteProperty(definition, key)
return
}
Object.assign(definition, { [key]: value })
}

afterEach(() => {
restoreOptionalProperty('maxPerTable', originalMaxPerTable)
restoreOptionalProperty('valueForConversion', originalValueForConversion)
})

describe('column type extension points', () => {
it('enforces registry-declared per-table limits', () => {
Object.assign(definition, { maxPerTable: 1 })
const columns: ColumnDefinition[] = [
{ name: 'first', type: 'string' },
{ name: 'second', type: 'string' },
]

expect(wouldExceedColumnTypeLimit(columns.slice(0, 1), 'string', 1)).toBe(true)
expect(validateColumnTypeLimits(columns)).toEqual([
`A table can have at most 1 ${definition.label} column`,
])
})

it('lets the source type normalize a value before conversion', () => {
Object.assign(definition, {
valueForConversion: (_value: unknown, target: ColumnDefinition) =>
target.type === 'number' ? 42 : 'unchanged',
})

expect(
valueForTypeConversion(
'stored-value',
{ name: 'source', type: 'string' },
{ name: 'target', type: 'number' }
)
).toBe(42)
expect(
valueForTypeConversion(
'stored-value',
{ name: 'source', type: 'number' },
{ name: 'target', type: 'string' }
)
).toBe('stored-value')
})

it('preserves an intentional null from source normalization', () => {
Object.assign(definition, {
valueForConversion: () => null,
})

expect(
valueForTypeConversion(
'stored-value',
{ name: 'source', type: 'string' },
{ name: 'target', type: 'number' }
)
).toBeNull()
})
})
38 changes: 38 additions & 0 deletions apps/sim/lib/table/column-types/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ export function isValueCompatible(value: unknown, target: ColumnDefinition): boo
return definition.coerce(value as JsonValue, target).ok
}

/** Applies source-owned normalization before a value is converted to another type. */
export function valueForTypeConversion(
value: JsonValue,
source: ColumnDefinition,
target: ColumnDefinition
): JsonValue {
const normalized = columnTypeOf(source).valueForConversion?.(value, target)
return normalized === undefined ? value : normalized
}

/** This type's own metadata errors; types carrying no metadata report none. */
export function validateTypeMetadata(column: ColumnDefinition): string[] {
return columnTypeOf(column).validateDefinition?.(column) ?? []
Expand All @@ -115,3 +125,31 @@ export function typeMetadataOf(column: ColumnDefinition): Partial<ColumnDefiniti
export function filterOperatorsFor(column: ColumnDefinition): ReadonlySet<string> | null {
return columnTypeOf(column).filterOperatorsFor?.(column) ?? null
}

/** Schema-level cardinality errors declared by column type definitions. */
export function validateColumnTypeLimits(columns: readonly ColumnDefinition[]): string[] {
const errors: string[] = []
for (const definition of ALL_COLUMN_TYPES) {
if (definition.maxPerTable === undefined) continue
if (wouldExceedColumnTypeLimit(columns, definition.id)) {
errors.push(`A table can have at most ${definition.maxPerTable} ${definition.label} column`)
}
}
return errors
}

/** Whether adding columns of a type would exceed its registry-declared table limit. */
export function wouldExceedColumnTypeLimit(
columns: readonly ColumnDefinition[],
type: ColumnType,
additionalColumns = 0
): boolean {
const definition = COLUMN_TYPE_REGISTRY[type]
if (definition.maxPerTable === undefined) return false

const count = columns.reduce(
(total, column) => total + (column.type === type ? 1 : 0),
additionalColumns
)
return count > definition.maxPerTable
}
Loading
Loading