Skip to content

Commit be46d36

Browse files
committed
feat(tables): colored select options, metadata controls, docs
Select options take a color, stored as a Badge variant name rather than a hex so each resolves to a token pair already tuned for light and dark — a stored hex is picked against one theme and fails in the other, and an unmapped value falls back to currentColor and renders as a black chip. New options cycle the palette by position so an authored option set is distinguishable without colouring each one by hand; existing options stay gray until someone picks. The config sidebar gains the decimal-places and include-time controls, and asks the registry which to show (`typeOwnsMetadataKey`) rather than testing type names — the one leak the skill's grep turned up in this change. The delete-column undo snapshot carried one flattened field per metadata key, so a new key was silently dropped on restore. It now captures `typeMetadataOf(column)` whole. Tests cover coerce/validate round-trips for the new types; the url-scheme and legacy-date cases were confirmed to fail with their guards removed.
1 parent eab6190 commit be46d36

12 files changed

Lines changed: 427 additions & 36 deletions

File tree

apps/docs/content/docs/en/tables/index.mdx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,25 @@ Every column has a type, which decides how its values are stored and validated.
2323
| **Text** | A free-form string | `"Acme Corp"` |
2424
| **Number** | A numeric value | `42` |
2525
| **Currency** | An amount in a currency you pick per column | `$1,234.56` |
26+
| **Percent** | A percentage | `25%` |
2627
| **Boolean** | `true` or `false` | `true` |
27-
| **Date** | A date | `2026-03-16` |
28-
| **JSON** | An object or array | `{ "tier": "pro" }` |
28+
| **Date** | A date, with or without a time | `2026-03-16` |
2929
| **Select** | One of a fixed set of options, or several | `Pro` |
30+
| **Email** | An email address | `person@example.com` |
31+
| **Phone** | A phone number | `+1 555 123 4567` |
32+
| **URL** | A link | `https://sim.ai` |
33+
| **Duration** | A length of time | `1:30:00` |
34+
| **JSON** | An object or array | `{ "tier": "pro" }` |
3035

3136
Types are enforced as you enter values, so a Number column only takes numbers.
3237

33-
A Currency column stores a plain number and renders it in the currency you choose for that column, so filters, sorts, and exports all see the amount itself. Changing a column's currency relabels it — it does not convert the amounts.
38+
Currency, Percent, and Duration columns all store a plain number, so filters, sorts, and exports see the amount itself rather than its formatting — `> 50%` and `>= 1h` are ordinary numeric comparisons. Changing a column's currency relabels it; it does not convert the amounts. Number and Percent columns take a decimal-place setting that changes how values are shown without rounding what is stored.
39+
40+
Email, Phone, and URL columns tidy values as you enter them — addresses are lower-cased, phone numbers stripped to digits, and links given a scheme — so the same value entered two ways matches. URL cells render as clickable links.
41+
42+
A Date column can carry a time of day or just a calendar date. Turning **Include time** off on a column that already has times will drop them.
43+
44+
Select options can each be given a color, which is used for the pill shown in the cell.
3445

3546
## Editing a table
3647

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

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import { X } from '@sim/emcn/icons'
66
import { toError } from '@sim/utils/errors'
77
import { findValidationIssue, isValidationError } from '@/lib/api/client/errors'
88
import type { ColumnDefinition, SelectOption } from '@/lib/table'
9+
import { typeOwnsMetadataKey } from '@/lib/table/column-types'
910
import {
1011
DEFAULT_CURRENCY_CODE,
1112
getCurrencyOptions,
1213
resolveCurrencyCode,
1314
} from '@/lib/table/currency'
15+
import { clampPrecision, DEFAULT_PRECISION } from '@/lib/table/precision'
1416
import {
1517
FieldError,
1618
RequiredLabel,
@@ -21,7 +23,7 @@ import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types'
2123

2224
/** Whether a column type carries an option set. */
2325
function isSelectType(type: ColumnDefinition['type']): boolean {
24-
return type === 'select'
26+
return typeOwnsMetadataKey(type, 'options')
2527
}
2628

2729
/**
@@ -129,14 +131,27 @@ function ColumnConfigBody({
129131
? resolveCurrencyCode(existingColumn?.currencyCode)
130132
: DEFAULT_CURRENCY_CODE
131133
)
134+
const [precisionInput, setPrecisionInput] = useState<number>(() =>
135+
clampPrecision(existingColumn?.precision)
136+
)
137+
const [includeTimeInput, setIncludeTimeInput] = useState<boolean>(() =>
138+
// Absent means a column created before the key existed, and those hold
139+
// instants — so the toggle reflects what the column actually stores.
140+
config.mode === 'edit' ? existingColumn?.includeTime !== false : false
141+
)
132142
const [showValidation, setShowValidation] = useState(false)
133143
const [nameError, setNameError] = useState<string | null>(null)
134144
const [optionsError, setOptionsError] = useState<string | null>(null)
135145

136146
const saveDisabled = updateColumn.isPending || addColumn.isPending
137147
const trimmedName = nameInput.trim()
138148
const wantsOptions = isSelectType(typeInput)
139-
const wantsCurrency = typeInput === 'currency'
149+
// Which metadata controls to show is a registry question, not a list of type
150+
// names: a type that later gains `precision` gets the control for free, and a
151+
// type that loses it cannot leave a stale control behind.
152+
const wantsCurrency = typeOwnsMetadataKey(typeInput, 'currencyCode')
153+
const wantsPrecision = typeOwnsMetadataKey(typeInput, 'precision')
154+
const wantsIncludeTime = typeOwnsMetadataKey(typeInput, 'includeTime')
140155
const trimmedOptions = optionsInput.map((o) => ({ ...o, name: o.name.trim() }))
141156

142157
/** Client-side option validation mirroring the server rules; returns an error message or null. */
@@ -171,6 +186,8 @@ function ColumnConfigBody({
171186
...(wantsOptions ? { options: trimmedOptions } : {}),
172187
...(wantsOptions && multipleInput ? { multiple: true } : {}),
173188
...(wantsCurrency ? { currencyCode: currencyInput } : {}),
189+
...(wantsPrecision ? { precision: precisionInput } : {}),
190+
...(wantsIncludeTime ? { includeTime: includeTimeInput } : {}),
174191
})
175192
toast.success(`Added "${trimmedName}"`)
176193
onClose()
@@ -191,6 +208,10 @@ function ColumnConfigBody({
191208
const multipleChanged = wantsOptions && !!existingColumn?.multiple !== multipleInput
192209
const currencyChanged =
193210
wantsCurrency && resolveCurrencyCode(existingColumn?.currencyCode) !== currencyInput
211+
const precisionChanged =
212+
wantsPrecision && clampPrecision(existingColumn?.precision) !== precisionInput
213+
const includeTimeChanged =
214+
wantsIncludeTime && (existingColumn?.includeTime !== false) !== includeTimeInput
194215

195216
const updates: {
196217
name?: string
@@ -199,6 +220,8 @@ function ColumnConfigBody({
199220
options?: SelectOption[]
200221
multiple?: boolean
201222
currencyCode?: string
223+
precision?: number
224+
includeTime?: boolean
202225
} = {
203226
...(renamed ? { name: trimmedName } : {}),
204227
...(typeChanged ? { type: typeInput } : {}),
@@ -209,6 +232,12 @@ function ColumnConfigBody({
209232
...(wantsCurrency && (typeChanged || currencyChanged)
210233
? { currencyCode: currencyInput }
211234
: {}),
235+
...(wantsPrecision && (typeChanged || precisionChanged)
236+
? { precision: precisionInput }
237+
: {}),
238+
...(wantsIncludeTime && (typeChanged || includeTimeChanged)
239+
? { includeTime: includeTimeInput }
240+
: {}),
212241
}
213242
if (Object.keys(updates).length === 0) {
214243
onClose()
@@ -306,6 +335,37 @@ function ColumnConfigBody({
306335
</>
307336
)}
308337

338+
{wantsPrecision && (
339+
<>
340+
<FieldDivider />
341+
<div className='flex flex-col gap-[9.5px]'>
342+
<RequiredLabel>Decimal places</RequiredLabel>
343+
<ChipInput
344+
type='number'
345+
inputMode='numeric'
346+
min={DEFAULT_PRECISION.min}
347+
max={DEFAULT_PRECISION.max}
348+
value={String(precisionInput)}
349+
onChange={(e) => setPrecisionInput(clampPrecision(Number(e.target.value)))}
350+
/>
351+
</div>
352+
</>
353+
)}
354+
355+
{wantsIncludeTime && (
356+
<>
357+
<FieldDivider />
358+
<div className='flex items-center justify-between pl-0.5'>
359+
<Label htmlFor='column-sidebar-include-time'>Include time</Label>
360+
<Switch
361+
id='column-sidebar-include-time'
362+
checked={includeTimeInput}
363+
onCheckedChange={(v) => setIncludeTimeInput(!!v)}
364+
/>
365+
</div>
366+
</>
367+
)}
368+
309369
{wantsOptions && (
310370
<>
311371
<FieldDivider />
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
'use client'
2+
3+
import { Badge, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@sim/emcn'
4+
import { SELECT_OPTION_COLORS, type SelectOptionColor } from '@/lib/table'
5+
6+
interface SelectColorPickerProps {
7+
color: SelectOptionColor | undefined
8+
onChange: (color: SelectOptionColor) => void
9+
/** Option name, so the trigger's accessible label says which option it colors. */
10+
optionName: string
11+
}
12+
13+
/**
14+
* Swatch dropdown for one option's pill color.
15+
*
16+
* Each swatch is a real `Badge` in the variant it selects, so the menu shows the
17+
* exact chrome the pill will have in both themes rather than an approximation
18+
* of it — the badge stays the single owner of its colors.
19+
*/
20+
export function SelectColorPicker({ color, onChange, optionName }: SelectColorPickerProps) {
21+
const current = color ?? 'gray'
22+
return (
23+
<DropdownMenu>
24+
<DropdownMenuTrigger
25+
className='size-7 shrink-0 rounded-md p-1'
26+
aria-label={`Color for ${optionName || 'option'}`}
27+
>
28+
<Badge variant={current} size='sm' className='size-full justify-center p-0'>
29+
<span className='sr-only'>{current}</span>
30+
</Badge>
31+
</DropdownMenuTrigger>
32+
<DropdownMenuContent align='end' className='grid grid-cols-5 gap-1 p-1.5'>
33+
{SELECT_OPTION_COLORS.map((swatch) => (
34+
<button
35+
key={swatch}
36+
type='button'
37+
onClick={() => onChange(swatch)}
38+
aria-label={swatch}
39+
aria-pressed={swatch === current}
40+
className='rounded-md p-0.5 transition-colors hover-hover:bg-[var(--surface-5)]'
41+
>
42+
<Badge variant={swatch} size='sm' className='size-5 justify-center p-0' />
43+
</button>
44+
))}
45+
</DropdownMenuContent>
46+
</DropdownMenu>
47+
)
48+
}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { useEffect, useRef, useState } from 'react'
44
import { Button, ChipInput } from '@sim/emcn'
55
import { X } from '@sim/emcn/icons'
66
import { generateShortId } from '@sim/utils/id'
7-
import type { SelectOption } from '@/lib/table'
7+
import { SELECT_OPTION_COLORS, type SelectOption } from '@/lib/table'
8+
import { SelectColorPicker } from './select-color-picker'
89

910
interface SelectOptionsEditorProps {
1011
options: SelectOption[]
@@ -44,17 +45,29 @@ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorPr
4445
onChange(options.filter((o) => o.id !== id))
4546
}
4647

47-
/** Typing into the trailing row promotes it to a real option and keeps focus. */
48+
/**
49+
* Typing into the trailing row promotes it to a real option and keeps focus.
50+
*
51+
* The color cycles through the palette by position rather than defaulting to
52+
* gray, so a freshly authored option set is distinguishable at a glance
53+
* without the user colouring each one by hand.
54+
*/
4855
const materialize = (name: string) => {
4956
const id = generateShortId()
50-
onChange([...options, { id, name }])
57+
const color = SELECT_OPTION_COLORS[options.length % SELECT_OPTION_COLORS.length]
58+
onChange([...options, { id, name, color }])
5159
setPendingFocusId(id)
5260
}
5361

5462
return (
5563
<div className='flex flex-col gap-1'>
5664
{options.map((option) => (
5765
<div key={option.id} className='flex items-center gap-1.5'>
66+
<SelectColorPicker
67+
color={option.color}
68+
onChange={(color) => update(option.id, { color })}
69+
optionName={option.name}
70+
/>
5871
<ChipInput
5972
ref={(el) => {
6073
if (el) inputRefs.current.set(option.id, el)
@@ -86,6 +99,7 @@ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorPr
8699
</div>
87100
))}
88101
<div className='flex items-center gap-1.5'>
102+
<span className='size-7 shrink-0' aria-hidden />
89103
<ChipInput
90104
ref={trailingRef}
91105
value=''

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-pill.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,17 @@ interface SelectPillProps {
3434
className?: string
3535
}
3636

37-
/** A single option pill, rendered through the shared neutral `Badge`. */
37+
/**
38+
* A single option pill, rendered through the shared `Badge`.
39+
*
40+
* The option's color IS a `Badge` variant name, so this passes it straight
41+
* through rather than mapping to classes — the badge stays the single owner of
42+
* its chrome. An option with no color keeps the neutral gray every option used
43+
* before colors existed.
44+
*/
3845
export function SelectPill({ option, size = 'sm', className }: SelectPillProps) {
3946
return (
40-
<Badge variant='gray' size={size} className={cn('max-w-full', className)}>
47+
<Badge variant={option.color ?? 'gray'} size={size} className={cn('max-w-full', className)}>
4148
<span className='truncate'>{option.name}</span>
4249
</Badge>
4350
)

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type {
2121
WorkflowGroup,
2222
} from '@/lib/table'
2323
import { getColumnId } from '@/lib/table/column-keys'
24-
import { columnTypeOf } from '@/lib/table/column-types'
24+
import { columnTypeOf, typeMetadataOf } from '@/lib/table/column-types'
2525
import { TABLE_LIMITS } from '@/lib/table/constants'
2626
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
2727
import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room'
@@ -3539,11 +3539,9 @@ export function TableGrid({
35393539
columnPosition: adjustedPosition >= 0 ? adjustedPosition : cols.length,
35403540
columnUnique: entry.def?.unique ?? false,
35413541
columnRequired: entry.def?.required ?? false,
3542-
// Without these a deleted select column can't be re-created — it is
3542+
// Without this a deleted select column can't be re-created — it is
35433543
// invalid with no options, and the saved cell data is option ids.
3544-
...(entry.def?.options ? { columnOptions: entry.def.options } : {}),
3545-
...(entry.def?.multiple ? { columnMultiple: true } : {}),
3546-
...(entry.def?.currencyCode ? { columnCurrencyCode: entry.def.currencyCode } : {}),
3544+
...(entry.def ? { columnMetadata: typeMetadataOf(entry.def) } : {}),
35473545
cellData,
35483546
previousOrder: orderSnapshot,
35493547
previousWidth,

apps/sim/hooks/use-table-undo.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -386,11 +386,10 @@ export function useTableUndo({
386386
type: action.columnType,
387387
required: action.columnRequired,
388388
unique: action.columnUnique,
389-
// A select column is rejected without its options, and the
390-
// cell data restored below is keyed by those option ids.
391-
...(action.columnOptions ? { options: action.columnOptions } : {}),
392-
...(action.columnMultiple ? { multiple: true } : {}),
393-
...(action.columnCurrencyCode ? { currencyCode: action.columnCurrencyCode } : {}),
389+
// Every type-specific key the column carried. A select column
390+
// is rejected without its options, and the cell data restored
391+
// below is keyed by those option ids.
392+
...action.columnMetadata,
394393
position: action.columnPosition,
395394
},
396395
{

apps/sim/lib/api/contracts/tables.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from '@/lib/table/constants'
3636
import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import'
3737
import type { ColumnTypeMetadata } from '@/lib/table/types'
38+
import { SELECT_OPTION_COLORS } from '@/lib/table/types'
3839

3940
export const domainObjectSchema = <T>() => z.custom<T>(isRecordLike)
4041

@@ -51,6 +52,8 @@ export const selectOptionSchema = z.object({
5152
.string()
5253
.min(1, 'Option name is required')
5354
.max(100, 'Option name must be 100 characters or less'),
55+
/** Pill color; absent renders the neutral gray options used before colors. */
56+
color: z.enum(SELECT_OPTION_COLORS).optional(),
5457
})
5558

5659
export const selectOptionsSchema = z

apps/sim/lib/copilot/tools/server/table/user-table.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import type {
8080
Filter,
8181
RowData,
8282
SelectOption,
83+
SelectOptionColor,
8384
SortSpec,
8485
TableDefinition,
8586
TableDeleteJobPayload,
@@ -92,6 +93,7 @@ import type {
9293
WorkflowGroupInputMapping,
9394
WorkflowGroupOutput,
9495
} from '@/lib/table/types'
96+
import { isSelectOptionColor } from '@/lib/table/types'
9597
import { markTableUpdateFailed, runTableUpdate } from '@/lib/table/update-runner'
9698
import { cancelWorkflowGroupRuns, runWorkflowColumn } from '@/lib/table/workflow-columns'
9799
import {
@@ -356,12 +358,27 @@ export function normalizeSelectOptionsInput(
356358
}
357359
const resolveId = (name: string): string => idByName.get(name.toLowerCase()) ?? generateShortId()
358360

361+
// An option's existing color survives a re-send that omits it, so an edit
362+
// that only renames options does not silently reset every pill to gray.
363+
const colorByName = new Map<string, SelectOptionColor>()
364+
for (const option of existing) {
365+
if (option.color) colorByName.set(option.name.toLowerCase(), option.color)
366+
}
367+
const resolveColor = (name: string, supplied: unknown): SelectOptionColor | undefined => {
368+
if (typeof supplied === 'string' && isSelectOptionColor(supplied)) return supplied
369+
return colorByName.get(name.toLowerCase())
370+
}
371+
359372
return raw.map((entry) => {
360-
if (typeof entry === 'string') return { id: resolveId(entry), name: entry }
361-
const e = (entry ?? {}) as { id?: unknown; name?: unknown }
373+
if (typeof entry === 'string') {
374+
const color = resolveColor(entry, undefined)
375+
return { id: resolveId(entry), name: entry, ...(color ? { color } : {}) }
376+
}
377+
const e = (entry ?? {}) as { id?: unknown; name?: unknown; color?: unknown }
362378
const name = typeof e.name === 'string' ? e.name : String(e.name ?? '')
363379
const id = typeof e.id === 'string' && e.id.length > 0 ? e.id : resolveId(name)
364-
return { id, name }
380+
const color = resolveColor(name, e.color)
381+
return { id, name, ...(color ? { color } : {}) }
365382
})
366383
}
367384

0 commit comments

Comments
 (0)