Skip to content

Commit 969a1ff

Browse files
committed
feat(tables): add rich column types — select, url, email, phone, currency, percent, rating
1 parent bc92a2c commit 969a1ff

23 files changed

Lines changed: 1021 additions & 127 deletions

File tree

apps/sim/app/api/table/[tableId]/columns/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
deleteColumn,
1616
renameColumn,
1717
updateColumnConstraints,
18+
updateColumnOptions,
1819
updateColumnType,
1920
} from '@/lib/table'
2021
import { accessError, checkAccess, normalizeColumn, rootErrorMessage } from '@/app/api/table/utils'
@@ -135,6 +136,13 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
135136
)
136137
}
137138

139+
if (updates.options !== undefined) {
140+
updatedTable = await updateColumnOptions(
141+
{ tableId, columnName: updates.name ?? validated.columnName, options: updates.options },
142+
requestId
143+
)
144+
}
145+
138146
if (!updatedTable) {
139147
return NextResponse.json({ error: 'No updates specified' }, { status: 400 })
140148
}

apps/sim/app/api/table/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
293293
type: col.type,
294294
required: col.required ?? false,
295295
unique: col.unique ?? false,
296+
...(col.options !== undefined ? { options: col.options } : {}),
296297
...(col.workflowGroupId ? { workflowGroupId: col.workflowGroupId } : {}),
297298
}
298299
}

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

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState } from 'react'
44
import { toError } from '@sim/utils/errors'
5+
import { generateShortId } from '@sim/utils/id'
56
import {
67
Button,
78
ChipCombobox,
@@ -11,7 +12,7 @@ import {
1112
Switch,
1213
toast,
1314
} from '@/components/emcn'
14-
import { X } from '@/components/emcn/icons'
15+
import { Plus, X } from '@/components/emcn/icons'
1516
import { findValidationIssue, isValidationError } from '@/lib/api/client/errors'
1617
import { cn } from '@/lib/core/utils/cn'
1718
import type { ColumnDefinition } from '@/lib/table'
@@ -79,6 +80,79 @@ function configKey(config: ColumnConfig): string {
7980
return config.mode === 'edit' ? `edit:${config.columnName}` : `create:${config.proposedName}`
8081
}
8182

83+
/** Local editing row for one select option — `id` keys the input across edits. */
84+
interface OptionDraft {
85+
id: string
86+
value: string
87+
}
88+
89+
function toOptionDrafts(options: string[]): OptionDraft[] {
90+
return options.map((value) => ({ id: generateShortId(), value }))
91+
}
92+
93+
/** Trims drafts, drops empties, and de-dupes case-insensitively (first wins). */
94+
function cleanOptionDrafts(drafts: OptionDraft[]): string[] {
95+
const seen = new Set<string>()
96+
const cleaned: string[] = []
97+
for (const draft of drafts) {
98+
const value = draft.value.trim()
99+
if (!value) continue
100+
const normalized = value.toLowerCase()
101+
if (seen.has(normalized)) continue
102+
seen.add(normalized)
103+
cleaned.push(value)
104+
}
105+
return cleaned
106+
}
107+
108+
interface SelectOptionsFieldProps {
109+
options: OptionDraft[]
110+
onChange: (options: OptionDraft[]) => void
111+
}
112+
113+
/** Editable list of a select column's predefined options. */
114+
function SelectOptionsField({ options, onChange }: SelectOptionsFieldProps) {
115+
return (
116+
<div className='flex flex-col gap-[9.5px]'>
117+
<Label>Options</Label>
118+
{options.map((option, index) => (
119+
<div key={option.id} className='flex items-center gap-1.5'>
120+
<ChipInput
121+
value={option.value}
122+
onChange={(e) =>
123+
onChange(
124+
options.map((o) => (o.id === option.id ? { ...o, value: e.target.value } : o))
125+
)
126+
}
127+
placeholder={`Option ${index + 1}`}
128+
spellCheck={false}
129+
autoComplete='off'
130+
className='min-w-0 flex-1'
131+
/>
132+
<Button
133+
variant='ghost'
134+
size='sm'
135+
onClick={() => onChange(options.filter((o) => o.id !== option.id))}
136+
className='!p-1 size-7 shrink-0'
137+
aria-label='Remove option'
138+
>
139+
<X className='size-[12px]' />
140+
</Button>
141+
</div>
142+
))}
143+
<Button
144+
variant='default'
145+
size='sm'
146+
onClick={() => onChange([...options, { id: generateShortId(), value: '' }])}
147+
className='self-start'
148+
>
149+
<Plus className='mr-1 size-[10px]' />
150+
Add option
151+
</Button>
152+
</div>
153+
)
154+
}
155+
82156
interface ColumnConfigBodyProps extends Omit<ColumnConfigSidebarProps, 'config'> {
83157
config: ColumnConfig
84158
}
@@ -103,6 +177,9 @@ function ColumnConfigBody({
103177
const [uniqueInput, setUniqueInput] = useState<boolean>(() =>
104178
config.mode === 'edit' ? !!existingColumn?.unique : false
105179
)
180+
const [optionDrafts, setOptionDrafts] = useState<OptionDraft[]>(() =>
181+
config.mode === 'edit' ? toOptionDrafts(existingColumn?.options ?? []) : []
182+
)
106183
const [showValidation, setShowValidation] = useState(false)
107184
const [nameError, setNameError] = useState<string | null>(null)
108185

@@ -115,12 +192,17 @@ function ColumnConfigBody({
115192
return
116193
}
117194

195+
const cleanedOptions = cleanOptionDrafts(optionDrafts)
196+
118197
try {
119198
if (config.mode === 'create') {
120199
await addColumn.mutateAsync({
121200
name: trimmedName,
122201
type: typeInput,
123202
...(uniqueInput ? { unique: true } : {}),
203+
...(typeInput === 'select' && cleanedOptions.length > 0
204+
? { options: cleanedOptions }
205+
: {}),
124206
})
125207
toast.success(`Added "${trimmedName}"`)
126208
onClose()
@@ -132,11 +214,20 @@ function ColumnConfigBody({
132214
const renamed = trimmedName !== (existingColumn?.name ?? config.columnName)
133215
const typeChanged = !!existingColumn && existingColumn.type !== typeInput
134216
const uniqueChanged = !!existingColumn && !!existingColumn.unique !== uniqueInput
217+
const optionsChanged =
218+
typeInput === 'select' &&
219+
JSON.stringify(cleanedOptions) !== JSON.stringify(existingColumn?.options ?? [])
135220

136-
const updates: { name?: string; type?: ColumnDefinition['type']; unique?: boolean } = {
221+
const updates: {
222+
name?: string
223+
type?: ColumnDefinition['type']
224+
unique?: boolean
225+
options?: string[]
226+
} = {
137227
...(renamed ? { name: trimmedName } : {}),
138228
...(typeChanged ? { type: typeInput } : {}),
139229
...(uniqueChanged ? { unique: uniqueInput } : {}),
230+
...(optionsChanged ? { options: cleanedOptions } : {}),
140231
}
141232
if (Object.keys(updates).length === 0) {
142233
onClose()
@@ -216,6 +307,13 @@ function ColumnConfigBody({
216307
</>
217308
)}
218309

310+
{typeInput === 'select' && (
311+
<>
312+
<FieldDivider />
313+
<SelectOptionsField options={optionDrafts} onChange={setOptionDrafts} />
314+
</>
315+
)}
316+
219317
<FieldDivider />
220318
<div className='flex flex-col gap-[9.5px]'>
221319
<div className='flex items-center justify-between pl-0.5'>

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type React from 'react'
2+
import { CircleChevronDown, DollarSign, Percent, Phone, Star } from 'lucide-react'
23
import {
34
Calendar as CalendarIcon,
5+
Link as LinkIcon,
6+
Mail,
47
PlayOutline,
58
TypeBoolean,
69
TypeJson,
@@ -27,6 +30,13 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [
2730
{ type: 'number', label: 'Number', icon: TypeNumber },
2831
{ type: 'boolean', label: 'Boolean', icon: TypeBoolean },
2932
{ type: 'date', label: 'Date', icon: CalendarIcon },
33+
{ type: 'select', label: 'Select', icon: CircleChevronDown },
34+
{ type: 'url', label: 'URL', icon: LinkIcon },
35+
{ type: 'email', label: 'Email', icon: Mail },
36+
{ type: 'phone', label: 'Phone', icon: Phone },
37+
{ type: 'currency', label: 'Currency', icon: DollarSign },
38+
{ type: 'percent', label: 'Percent', icon: Percent },
39+
{ type: 'rating', label: 'Rating', icon: Star },
3040
{ type: 'json', label: 'JSON', icon: TypeJson },
3141
{ type: 'workflow', label: 'Workflow', icon: PlayOutline },
3242
]

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getErrorMessage } from '@sim/utils/errors'
66
import { useParams } from 'next/navigation'
77
import {
88
Checkbox,
9+
ChipCombobox,
910
ChipConfirmModal,
1011
ChipModal,
1112
ChipModalBody,
@@ -17,6 +18,7 @@ import {
1718
Label,
1819
} from '@/components/emcn'
1920
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
21+
import { getColumnStorageType } from '@/lib/table/constants'
2022
import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables'
2123
import { cleanCellValue, formatValueForInput } from '../../utils'
2224

@@ -250,16 +252,61 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
250252
)
251253
}
252254

255+
if (column.type === 'select') {
256+
const options = column.options ?? []
257+
const current = typeof value === 'string' ? value : ''
258+
const selectOptions = [
259+
...options.map((option) => ({ label: option, value: option })),
260+
...(current && !options.includes(current) ? [{ label: current, value: current }] : []),
261+
]
262+
return (
263+
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
264+
<ChipCombobox
265+
options={selectOptions}
266+
value={current}
267+
onChange={(v) => onChange(v)}
268+
placeholder='Select option'
269+
maxHeight={260}
270+
/>
271+
</ChipModalField>
272+
)
273+
}
274+
275+
if (column.type === 'email') {
276+
return (
277+
<ChipModalField
278+
type='email'
279+
title={title}
280+
required={column.required}
281+
hint={hint}
282+
value={formatValueForInput(value, column.type)}
283+
onChange={onChange}
284+
placeholder={`Enter ${column.name}`}
285+
/>
286+
)
287+
}
288+
289+
const inputType =
290+
getColumnStorageType(column.type) === 'number'
291+
? 'number'
292+
: (FIELD_INPUT_TYPES[column.type] ?? 'text')
293+
253294
return (
254295
<ChipModalField
255296
type='input'
256297
title={title}
257298
required={column.required}
258299
hint={hint}
259-
inputType={column.type === 'number' ? 'number' : 'text'}
300+
inputType={inputType}
260301
value={formatValueForInput(value, column.type)}
261302
onChange={onChange}
262303
placeholder={`Enter ${column.name}`}
263304
/>
264305
)
265306
}
307+
308+
/** Browser input types for rich string-backed columns (default: `text`). */
309+
const FIELD_INPUT_TYPES: Partial<Record<ColumnDefinition['type'], 'url' | 'tel'>> = {
310+
url: 'url',
311+
phone: 'tel',
312+
}

0 commit comments

Comments
 (0)