diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index 7eecb5ee466..d59e3ca8b60 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -15,6 +15,7 @@ import { deleteColumn, renameColumn, updateColumnConstraints, + updateColumnOptions, updateColumnType, } from '@/lib/table' import { accessError, checkAccess, normalizeColumn, rootErrorMessage } from '@/app/api/table/utils' @@ -118,7 +119,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu if (updates.type) { updatedTable = await updateColumnType( - { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, + { + tableId, + columnName: updates.name ?? validated.columnName, + newType: updates.type, + // Applied inside updateColumnType's transaction so a combined + // type+options change is atomic. + ...(updates.options !== undefined ? { options: updates.options } : {}), + }, requestId ) } @@ -135,6 +143,13 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu ) } + if (updates.options !== undefined && !updates.type) { + updatedTable = await updateColumnOptions( + { tableId, columnName: updates.name ?? validated.columnName, options: updates.options }, + requestId + ) + } + if (!updatedTable) { return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) } diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index c8dde913132..444e28f564b 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -293,6 +293,7 @@ export function normalizeColumn(col: ColumnDefinition): ColumnDefinition { type: col.type, required: col.required ?? false, unique: col.unique ?? false, + ...(col.options !== undefined ? { options: col.options } : {}), ...(col.workflowGroupId ? { workflowGroupId: col.workflowGroupId } : {}), } } diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index 0eeebfb99ce..249fde5b6d6 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -14,6 +14,7 @@ import { deleteColumn, renameColumn, updateColumnConstraints, + updateColumnOptions, updateColumnType, } from '@/lib/table' import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' @@ -140,7 +141,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu if (updates.type) { updatedTable = await updateColumnType( - { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, + { + tableId, + columnName: updates.name ?? validated.columnName, + newType: updates.type, + // Applied inside updateColumnType's transaction so a combined + // type+options change is atomic. + ...(updates.options !== undefined ? { options: updates.options } : {}), + }, requestId ) } @@ -157,6 +165,13 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu ) } + if (updates.options !== undefined && !updates.type) { + updatedTable = await updateColumnOptions( + { tableId, columnName: updates.name ?? validated.columnName, options: updates.options }, + requestId + ) + } + if (!updatedTable) { return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx index 003680668d0..010f315d2ce 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { toError } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' import { Button, ChipCombobox, @@ -11,7 +12,7 @@ import { Switch, toast, } from '@/components/emcn' -import { X } from '@/components/emcn/icons' +import { Plus, X } from '@/components/emcn/icons' import { findValidationIssue, isValidationError } from '@/lib/api/client/errors' import { cn } from '@/lib/core/utils/cn' import type { ColumnDefinition } from '@/lib/table' @@ -79,6 +80,79 @@ function configKey(config: ColumnConfig): string { return config.mode === 'edit' ? `edit:${config.columnName}` : `create:${config.proposedName}` } +/** Local editing row for one select option — `id` keys the input across edits. */ +interface OptionDraft { + id: string + value: string +} + +function toOptionDrafts(options: string[]): OptionDraft[] { + return options.map((value) => ({ id: generateShortId(), value })) +} + +/** Trims drafts, drops empties, and de-dupes case-insensitively (first wins). */ +function cleanOptionDrafts(drafts: OptionDraft[]): string[] { + const seen = new Set() + const cleaned: string[] = [] + for (const draft of drafts) { + const value = draft.value.trim() + if (!value) continue + const normalized = value.toLowerCase() + if (seen.has(normalized)) continue + seen.add(normalized) + cleaned.push(value) + } + return cleaned +} + +interface SelectOptionsFieldProps { + options: OptionDraft[] + onChange: (options: OptionDraft[]) => void +} + +/** Editable list of a select column's predefined options. */ +function SelectOptionsField({ options, onChange }: SelectOptionsFieldProps) { + return ( +
+ + {options.map((option, index) => ( +
+ + onChange( + options.map((o) => (o.id === option.id ? { ...o, value: e.target.value } : o)) + ) + } + placeholder={`Option ${index + 1}`} + spellCheck={false} + autoComplete='off' + className='min-w-0 flex-1' + /> + +
+ ))} + +
+ ) +} + interface ColumnConfigBodyProps extends Omit { config: ColumnConfig } @@ -103,6 +177,9 @@ function ColumnConfigBody({ const [uniqueInput, setUniqueInput] = useState(() => config.mode === 'edit' ? !!existingColumn?.unique : false ) + const [optionDrafts, setOptionDrafts] = useState(() => + config.mode === 'edit' ? toOptionDrafts(existingColumn?.options ?? []) : [] + ) const [showValidation, setShowValidation] = useState(false) const [nameError, setNameError] = useState(null) @@ -115,12 +192,17 @@ function ColumnConfigBody({ return } + const cleanedOptions = cleanOptionDrafts(optionDrafts) + try { if (config.mode === 'create') { await addColumn.mutateAsync({ name: trimmedName, type: typeInput, ...(uniqueInput ? { unique: true } : {}), + ...(typeInput === 'select' && cleanedOptions.length > 0 + ? { options: cleanedOptions } + : {}), }) toast.success(`Added "${trimmedName}"`) onClose() @@ -132,11 +214,20 @@ function ColumnConfigBody({ const renamed = trimmedName !== (existingColumn?.name ?? config.columnName) const typeChanged = !!existingColumn && existingColumn.type !== typeInput const uniqueChanged = !!existingColumn && !!existingColumn.unique !== uniqueInput + const optionsChanged = + typeInput === 'select' && + JSON.stringify(cleanedOptions) !== JSON.stringify(existingColumn?.options ?? []) - const updates: { name?: string; type?: ColumnDefinition['type']; unique?: boolean } = { + const updates: { + name?: string + type?: ColumnDefinition['type'] + unique?: boolean + options?: string[] + } = { ...(renamed ? { name: trimmedName } : {}), ...(typeChanged ? { type: typeInput } : {}), ...(uniqueChanged ? { unique: uniqueInput } : {}), + ...(optionsChanged ? { options: cleanedOptions } : {}), } if (Object.keys(updates).length === 0) { onClose() @@ -216,6 +307,13 @@ function ColumnConfigBody({ )} + {typeInput === 'select' && ( + <> + + + + )} +
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts index 6c9f31ade67..8ed2041152e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts @@ -1,6 +1,9 @@ import type React from 'react' +import { CircleChevronDown, DollarSign, Percent, Phone, Star } from 'lucide-react' import { Calendar as CalendarIcon, + Link as LinkIcon, + Mail, PlayOutline, TypeBoolean, TypeJson, @@ -27,6 +30,13 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [ { type: 'number', label: 'Number', icon: TypeNumber }, { type: 'boolean', label: 'Boolean', icon: TypeBoolean }, { type: 'date', label: 'Date', icon: CalendarIcon }, + { type: 'select', label: 'Select', icon: CircleChevronDown }, + { type: 'url', label: 'URL', icon: LinkIcon }, + { type: 'email', label: 'Email', icon: Mail }, + { type: 'phone', label: 'Phone', icon: Phone }, + { type: 'currency', label: 'Currency', icon: DollarSign }, + { type: 'percent', label: 'Percent', icon: Percent }, + { type: 'rating', label: 'Rating', icon: Star }, { type: 'json', label: 'JSON', icon: TypeJson }, { type: 'workflow', label: 'Workflow', icon: PlayOutline }, ] diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx index 17ac4992c59..96b50936d51 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx @@ -6,6 +6,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' import { Checkbox, + ChipCombobox, ChipConfirmModal, ChipModal, ChipModalBody, @@ -17,6 +18,7 @@ import { Label, } from '@/components/emcn' import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table' +import { getColumnStorageType } from '@/lib/table/constants' import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables' import { cleanCellValue, formatValueForInput } from '../../utils' @@ -250,16 +252,65 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) { ) } + if (column.type === 'select') { + const options = column.options ?? [] + const current = typeof value === 'string' ? value : '' + // Match the stored value case-insensitively (same as the grid's tag + // rendering) so a casing variant selects its canonical option instead of + // appearing as a duplicate entry. + const matched = options.find((option) => option.toLowerCase() === current.toLowerCase()) + const selectOptions = [ + ...options.map((option) => ({ label: option, value: option })), + ...(current && matched === undefined ? [{ label: current, value: current }] : []), + ] + return ( + + onChange(v)} + placeholder='Select option' + maxHeight={260} + /> + + ) + } + + if (column.type === 'email') { + return ( + + ) + } + + const inputType = + getColumnStorageType(column.type) === 'number' + ? 'number' + : (FIELD_INPUT_TYPES[column.type] ?? 'text') + return ( ) } + +/** Browser input types for rich string-backed columns (default: `text`). */ +const FIELD_INPUT_TYPES: Partial> = { + url: 'url', + phone: 'tel', +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index c4c7904a711..ae38049bd07 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -2,12 +2,20 @@ import type React from 'react' import { useEffect, useRef, useState } from 'react' +import { Phone, Star } from 'lucide-react' import { parse } from 'tldts' import { Badge, Checkbox, Tooltip } from '@/components/emcn' +import { Mail } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import type { RowExecutionMetadata } from '@/lib/table' +import { RATING_MAX } from '@/lib/table/constants' import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils' -import { storageToDisplay } from '../../../utils' +import { + formatCurrencyDisplay, + formatPercentDisplay, + selectBadgeVariant, + storageToDisplay, +} from '../../../utils' import type { DisplayColumn } from '../types' import { SimResourceCell, type SimResourceType } from './sim-resource-cell' @@ -28,6 +36,12 @@ export type CellRenderKind = | { kind: 'json'; text: string } | { kind: 'date'; text: string } | { kind: 'url'; text: string; href: string; domain: string } + | { kind: 'email'; text: string; href: string } + | { kind: 'phone'; text: string; href: string } + /** `option` — canonical predefined option matched case-insensitively; + * undefined means an off-list value (renders as a gray tag). */ + | { kind: 'select'; text: string; option?: string } + | { kind: 'rating'; value: number } | { kind: 'sim-resource' workspaceId: string @@ -115,13 +129,77 @@ export function resolveCellRender({ if (column.type === 'boolean') return { kind: 'boolean', checked: Boolean(value) } if (isNull) return { kind: 'empty' } - if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) } - if (column.type === 'date') return { kind: 'date', text: String(value) } - if (column.type === 'string') { - const text = stringifyValue(value) - return resolveLinkKind(text, currentWorkspaceId) ?? { kind: 'text', text } + + switch (column.type) { + case 'json': + return { kind: 'json', text: JSON.stringify(value) } + case 'date': + return { kind: 'date', text: String(value) } + case 'string': + case 'url': { + const text = stringifyValue(value) + return resolveLinkKind(text, currentWorkspaceId) ?? { kind: 'text', text } + } + case 'email': { + const text = stringifyValue(value) + return resolveEmailKind(text) ?? { kind: 'text', text } + } + case 'phone': { + const text = stringifyValue(value) + return resolvePhoneKind(text) ?? { kind: 'text', text } + } + case 'select': { + if (isEmpty) return { kind: 'empty' } + const text = stringifyValue(value) + const lower = text.toLowerCase() + const option = (column.options ?? []).find((o) => o.toLowerCase() === lower) + return { kind: 'select', text, option } + } + case 'currency': + return { + kind: 'text', + text: typeof value === 'number' ? formatCurrencyDisplay(value) : stringifyValue(value), + } + case 'percent': + return { + kind: 'text', + text: typeof value === 'number' ? formatPercentDisplay(value) : stringifyValue(value), + } + case 'rating': + return typeof value === 'number' + ? { kind: 'rating', value } + : { kind: 'text', text: stringifyValue(value) } + default: + return { kind: 'text', text: stringifyValue(value) } } - return { kind: 'text', text: stringifyValue(value) } +} + +/** + * Lenient whole-string email shape for promoting a cell to a mailto link. + * Intentionally looser than signup validation — a non-match just renders as + * plain text, so false negatives cost a link, never data. + */ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +const PHONE_RE = /^\+?[\d\s().-]+$/ +const PHONE_DIGIT_RE = /\d/g +const PHONE_SEPARATOR_RE = /[\s().-]/g + +/** Promotes a cell value that is wholly an email address to a mailto link, else null. */ +function resolveEmailKind(text: string): Extract | null { + const trimmed = text.trim() + if (!EMAIL_RE.test(trimmed)) return null + return { kind: 'email', text, href: `mailto:${trimmed}` } +} + +/** + * Promotes a cell value that looks like a phone number (optional `+`, then + * digits with common separators, at least 3 digits) to a tel link, else null. + */ +function resolvePhoneKind(text: string): Extract | null { + const trimmed = text.trim() + if (!PHONE_RE.test(trimmed)) return null + if ((trimmed.match(PHONE_DIGIT_RE)?.length ?? 0) < 3) return null + return { kind: 'phone', text, href: `tel:${trimmed.replace(PHONE_SEPARATOR_RE, '')}` } } function stringifyValue(value: unknown): string { @@ -350,22 +428,57 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle e.currentTarget.style.display = 'none' }} /> - e.stopPropagation()} - onDoubleClick={(e) => e.stopPropagation()} + + + ) + + case 'email': + case 'phone': { + const Icon = kind.kind === 'email' ? Mail : Phone + return ( + + + + + ) + } + + case 'select': + return ( + + - {kind.text} - + {kind.text} + ) + case 'rating': { + const filled = Math.min(RATING_MAX, Math.max(0, Math.round(kind.value))) + return ( + + {Array.from({ length: RATING_MAX }, (_, i) => ( + + ))} + + ) + } + case 'sim-resource': return ( {children}
} +interface CellLinkProps { + href: string + text: string + isEditing: boolean + /** Open in a new tab (http links). mailto:/tel: stay in-place. */ + external?: boolean +} + +/** Shared anchor chrome for url / email / phone cells. */ +function CellLink({ href, text, isEditing, external = false }: CellLinkProps) { + return ( + e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + > + {text} + + ) +} + const TYPEWRITER_MS_PER_CHAR = 15 /** diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx index ccbc410e490..8a40adb38eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx @@ -1,14 +1,23 @@ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' -import { DatePicker } from '@/components/emcn' +import { + Badge, + DatePicker, + Popover, + PopoverAnchor, + PopoverContent, + PopoverItem, +} from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import type { ColumnDefinition } from '@/lib/table' +import { getColumnStorageType } from '@/lib/table/constants' import type { SaveReason } from '../../../types' import { cleanCellValue, displayToStorage, formatValueForInput, + selectBadgeVariant, storageToDisplay, } from '../../../utils' @@ -131,6 +140,144 @@ function InlineDateEditor({ ) } +/** + * Inline editor for `select` columns — filter input + floating option list. + * Typing filters the column's predefined options; Enter or click picks the + * highlighted option. A draft that matches no option saves as-is (option + * membership is a soft constraint), and an empty draft clears the cell. + */ +function InlineSelectEditor({ + value, + column, + initialCharacter, + onSave, + onCancel, +}: InlineEditorProps) { + const inputRef = useRef(null) + const doneRef = useRef(false) + const blurTimeoutRef = useRef | undefined>(undefined) + + const [draft, setDraft] = useState(() => + initialCharacter !== undefined ? initialCharacter : formatValueForInput(value, column.type) + ) + const [highlightIndex, setHighlightIndex] = useState(0) + // Enter/Tab only auto-pick the highlighted option after the user has arrow- + // navigated, or typed a non-empty filter; a bare Enter on an untouched draft + // saves it verbatim, so confirming a cell never silently rewrites its value + // to an option's casing. Typeahead-opened editors start typed. + const [typed, setTyped] = useState(initialCharacter !== undefined) + const [navigated, setNavigated] = useState(false) + + const options = column.options ?? [] + const query = draft.trim().toLowerCase() + const filtered = query ? options.filter((o) => o.toLowerCase().includes(query)) : options + const highlighted = filtered[Math.min(highlightIndex, filtered.length - 1)] + + useEffect(() => { + const input = inputRef.current + if (!input) return + input.focus() + if (initialCharacter !== undefined) { + const len = input.value.length + input.setSelectionRange(len, len) + } else { + input.select() + } + }, []) + + useEffect(() => () => clearTimeout(blurTimeoutRef.current), []) + + const doSave = useCallback( + (reason: SaveReason, picked?: string) => { + if (doneRef.current) return + doneRef.current = true + clearTimeout(blurTimeoutRef.current) + const raw = (picked ?? draft).trim() + onSave(raw === '' ? null : raw, reason) + }, + [draft, onSave] + ) + + // The option Enter/Tab/blur should apply: the highlighted one once the user + // has arrow-navigated or typed a non-empty filter, else the raw draft. + const resolvePick = useCallback( + () => + highlighted !== undefined && (navigated || (typed && query !== '')) ? highlighted : undefined, + [highlighted, navigated, typed, query] + ) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault() + if (filtered.length === 0) return + setNavigated(true) + const delta = e.key === 'ArrowDown' ? 1 : -1 + setHighlightIndex((i) => (i + delta + filtered.length) % filtered.length) + } else if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault() + const reason: SaveReason = e.key === 'Tab' ? (e.shiftKey ? 'shift-tab' : 'tab') : 'enter' + doSave(reason, resolvePick()) + } else if (e.key === 'Escape') { + e.preventDefault() + doneRef.current = true + clearTimeout(blurTimeoutRef.current) + onCancel() + } + }, + [doSave, onCancel, filtered.length, resolvePick] + ) + + const handleBlur = useCallback(() => { + blurTimeoutRef.current = setTimeout(() => doSave('blur', resolvePick()), 200) + }, [doSave, resolvePick]) + + return ( + + + { + setDraft(e.target.value) + setHighlightIndex(0) + setTyped(true) + setNavigated(false) + }} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + className='w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none' + /> + + {filtered.length > 0 && ( + e.preventDefault()} + > + {filtered.map((option, i) => ( + setHighlightIndex(i)} + onMouseDown={(e) => { + e.preventDefault() + doSave('enter', option) + }} + > + + {option} + + + ))} + + )} + + ) +} + /** Inline editor for `string`/`number`/`json` columns — single-line text input. Number columns use `type="number"` so the browser rejects non-numeric input. */ function InlineTextEditor({ value, @@ -190,7 +337,7 @@ function InlineTextEditor({ } } - const isNumber = column.type === 'number' + const isNumber = getColumnStorageType(column.type) === 'number' return ( } + if (props.column.type === 'select') { + return + } return } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 87786d07984..b1f0cee6ca0 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -13,7 +13,7 @@ import { cn } from '@/lib/core/utils/cn' import { captureEvent } from '@/lib/posthog/client' import type { ColumnDefinition, Filter, TableRow as TableRowType, WorkflowGroup } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' -import { TABLE_LIMITS } from '@/lib/table/constants' +import { getColumnStorageType, RATING_MAX, TABLE_LIMITS } from '@/lib/table/constants' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useAddTableColumn, @@ -36,6 +36,8 @@ import { useContextMenu, useTable } from '../../hooks' import type { EditingCell, QueryOptions, SaveReason } from '../../types' import { cleanCellValue, + formatCurrencyDisplay, + formatPercentDisplay, generateColumnName as sharedGenerateColumnName, storageToDisplay, } from '../../utils' @@ -76,6 +78,10 @@ const EMPTY_FIND_MATCHES: readonly TableFindMatch[] = Object.freeze([]) const COL_WIDTH_MIN = 80 const COL_WIDTH_AUTO_FIT_MAX = 1000 +/** Rendered width of a rating cell: RATING_MAX 13px stars with 3px gaps. */ +const RATING_STARS_WIDTH = RATING_MAX * 13 + (RATING_MAX - 1) * 3 +/** Horizontal chrome a select cell's Badge adds around its text (`px-[7px]`). */ +const SELECT_BADGE_PADDING_X = 14 const ROW_HEIGHT_ESTIMATE = 35 /** @@ -1372,27 +1378,36 @@ export function TableGrid({ maxWidth = Math.max(maxWidth, measure.getBoundingClientRect().width + 57) measure.className = 'text-small' - for (const row of currentRows) { - const val = row.data[column.key] - if (val == null) continue - let text: string - if (column.type === 'json') { - if (typeof val === 'string') { - text = val - } else { - try { - text = JSON.stringify(val) - } catch { - text = String(val) + if (column.type === 'rating') { + maxWidth = Math.max(maxWidth, RATING_STARS_WIDTH + 17) + } else { + const cellPadding = column.type === 'select' ? 17 + SELECT_BADGE_PADDING_X : 17 + for (const row of currentRows) { + const val = row.data[column.key] + if (val == null) continue + let text: string + if (column.type === 'json') { + if (typeof val === 'string') { + text = val + } else { + try { + text = JSON.stringify(val) + } catch { + text = String(val) + } } + } else if (column.type === 'date') { + text = storageToDisplay(String(val)) + } else if (column.type === 'currency' && typeof val === 'number') { + text = formatCurrencyDisplay(val) + } else if (column.type === 'percent' && typeof val === 'number') { + text = formatPercentDisplay(val) + } else { + text = String(val) } - } else if (column.type === 'date') { - text = storageToDisplay(String(val)) - } else { - text = String(val) + measure.textContent = text + maxWidth = Math.max(maxWidth, measure.getBoundingClientRect().width + cellPadding) } - measure.textContent = text - maxWidth = Math.max(maxWidth, measure.getBoundingClientRect().width + 17) } } finally { host.removeChild(measure) @@ -1957,8 +1972,13 @@ export function TableGrid({ setSelectionFocus(null) setIsColumnSelection(false) - // Date/number: use inline editor (calendar picker / numeric input). - if ((column?.type === 'date' || column?.type === 'number') && canEditRef.current) { + // Date/select/number-backed: use inline editor (picker / option list / numeric input). + const usesInlineEditor = + column && + (column.type === 'date' || + column.type === 'select' || + getColumnStorageType(column.type) === 'number') + if (usesInlineEditor && canEditRef.current) { setEditingCell({ rowId, columnName }) setInitialCharacter(null) return @@ -2379,7 +2399,7 @@ export function TableGrid({ // workflow's value if they want. Booleans toggle on space/click — // typeahead doesn't apply to them. if (!col || col.type === 'boolean') return - if (col.type === 'number' && !/[\d.-]/.test(e.key)) return + if (getColumnStorageType(col.type) === 'number' && !/[\d.-]/.test(e.key)) return if (col.type === 'date' && !/[\d\-/]/.test(e.key)) return e.preventDefault() @@ -3113,6 +3133,7 @@ export function TableGrid({ columnPosition: adjustedPosition >= 0 ? adjustedPosition : cols.length, columnUnique: entry.def?.unique ?? false, columnRequired: entry.def?.required ?? false, + ...(entry.def?.options !== undefined ? { columnOptions: entry.def.options } : {}), cellData, previousOrder: orderSnapshot, previousWidth, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx index 17456028823..9a1fb83c883 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx @@ -43,6 +43,7 @@ import type { } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' +import { getColumnStorageType } from '@/lib/table/constants' import { type FlattenOutputsBlockInput, type FlattenOutputsEdgeInput, @@ -154,7 +155,7 @@ interface WorkflowStatePayload { } function tableColumnTypeToInputType(colType: ColumnDefinition['type'] | undefined): string { - switch (colType) { + switch (colType === undefined ? undefined : getColumnStorageType(colType)) { case 'number': return 'number' case 'boolean': diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts index 55e310c3630..9ad12806af7 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts @@ -1,6 +1,59 @@ import type { ColumnDefinition } from '@/lib/table' +import { getColumnStorageType, RATING_MAX } from '@/lib/table/constants' -type BadgeVariant = 'green' | 'blue' | 'purple' | 'orange' | 'teal' | 'gray' +/** + * Number formatters for currency/percent cells, created lazily on first + * format call. Cell values only render after the client-side row fetch, so + * these always initialize in the browser with `navigator.language` — never at + * module load during SSR, where the locale would diverge and risk hydration + * mismatches. USD is the default display currency until per-column currency + * config exists. + */ +let currencyFormatter: Intl.NumberFormat | undefined +let percentFormatter: Intl.NumberFormat | undefined + +function displayLocale(): string { + return typeof navigator === 'undefined' ? 'en-US' : navigator.language +} + +/** Formats a currency cell's numeric value for display in the user's locale. */ +export function formatCurrencyDisplay(value: number): string { + currencyFormatter ??= new Intl.NumberFormat(displayLocale(), { + style: 'currency', + currency: 'USD', + }) + return currencyFormatter.format(value) +} + +/** Formats a percent cell's numeric value for display, e.g. `12.5` → `12.5%`. */ +export function formatPercentDisplay(value: number): string { + percentFormatter ??= new Intl.NumberFormat(displayLocale(), { maximumFractionDigits: 2 }) + return `${percentFormatter.format(value)}%` +} + +/** + * Tag palette for select values. The variant is derived from a hash of the + * value so a given option keeps its color across rows, reloads, and option + * reordering without persisting color state. + */ +const SELECT_BADGE_VARIANTS = [ + 'green', + 'blue', + 'purple', + 'orange', + 'teal', + 'cyan', + 'pink', + 'amber', +] as const + +export function selectBadgeVariant(value: string): (typeof SELECT_BADGE_VARIANTS)[number] { + let hash = 0 + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) | 0 + } + return SELECT_BADGE_VARIANTS[Math.abs(hash) % SELECT_BADGE_VARIANTS.length] +} /** * Pick a fresh "untitled[_N]" name not already taken by `columns`. Used by @@ -18,46 +71,33 @@ export function generateColumnName(columns: ReadonlyArray<{ name: string }>): st } /** - * Returns the appropriate badge color variant for a column type - */ -export function getTypeBadgeVariant(type: string): BadgeVariant { - switch (type) { - case 'string': - return 'green' - case 'number': - return 'blue' - case 'boolean': - return 'purple' - case 'json': - return 'orange' - case 'date': - return 'teal' - default: - return 'gray' - } -} - -/** - * Coerce a raw input value to the appropriate type for a column. - * Throws on invalid JSON. + * Coerce a raw input value to the appropriate storage primitive for a column. + * Rich types coerce as their primitive (e.g. `currency` parses as a number); + * `rating` additionally rounds and clamps to 0..{@link RATING_MAX} so star + * edits always land on a renderable value. Throws on invalid JSON. */ export function cleanCellValue(value: unknown, column: ColumnDefinition): unknown { - if (column.type === 'number') { + const storageType = getColumnStorageType(column.type) + if (storageType === 'number') { if (value === '') return null const num = Number(value) - return Number.isNaN(num) ? null : num + if (Number.isNaN(num)) return null + if (column.type === 'rating') { + return Math.min(RATING_MAX, Math.max(0, Math.round(num))) + } + return num } - if (column.type === 'json') { + if (storageType === 'json') { if (typeof value === 'string') { if (value === '') return null return JSON.parse(value) } return value } - if (column.type === 'boolean') { + if (storageType === 'boolean') { return Boolean(value) } - if (column.type === 'date') { + if (storageType === 'date') { if (value === '' || value === null || value === undefined) return null const str = String(value) return Number.isNaN(Date.parse(str)) ? null : str @@ -74,10 +114,11 @@ export function cleanCellValue(value: unknown, column: ColumnDefinition): unknow */ export function formatValueForInput(value: unknown, type: string): string { if (value === null || value === undefined) return '' - if (type === 'json') { + const storageType = getColumnStorageType(type) + if (storageType === 'json') { return typeof value === 'string' ? value : JSON.stringify(value) } - if (type === 'date' && value) { + if (storageType === 'date' && value) { const str = String(value) const match = str.match(/^(\d{4})-(\d{2})-(\d{2})/) if (match) return match[0] diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 4014043c71e..b813301d254 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -271,6 +271,7 @@ export function useTableUndo({ type: action.columnType, required: action.columnRequired, unique: action.columnUnique, + ...(action.columnOptions !== undefined ? { options: action.columnOptions } : {}), position: action.columnPosition, }, { @@ -383,9 +384,11 @@ export function useTableUndo({ case 'update-column-type': { const type = direction === 'undo' ? action.previousType : action.newType + const options = direction === 'undo' ? action.previousOptions : action.newOptions + const restoreOptions = type === 'select' && options !== undefined updateColumnMutation.mutate({ columnName: action.columnName, - updates: { type }, + updates: { type, ...(restoreOptions ? { options } : {}) }, }) break } diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index a21c1502f3c..0d1e063b856 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -59,6 +59,38 @@ const descriptionSchema = z `Description must be ${TABLE_LIMITS.MAX_DESCRIPTION_LENGTH} characters or less` ) +/** + * Predefined options for `select` columns. Membership is a soft constraint — + * cell values outside the list stay valid — so this only bounds the option + * list itself (count, per-option length, case-insensitive uniqueness). + */ +export const columnOptionsSchema = z + .array( + z + .string() + .min(1, 'Option cannot be empty') + .max( + TABLE_LIMITS.MAX_SELECT_OPTION_LENGTH, + `Option must be ${TABLE_LIMITS.MAX_SELECT_OPTION_LENGTH} characters or less` + ) + ) + .max( + TABLE_LIMITS.MAX_SELECT_OPTIONS, + `Cannot have more than ${TABLE_LIMITS.MAX_SELECT_OPTIONS} options` + ) + .refine((options) => new Set(options.map((o) => o.toLowerCase())).size === options.length, { + message: 'Options must be unique', + }) + +/** Predefined options are only meaningful on `select` columns. */ +const columnOptionsTypeRefine: [ + (column: { type?: string; options?: string[] }) => boolean, + { message: string; path: string[] }, +] = [ + (column) => column.options === undefined || column.type === 'select', + { message: 'options are only allowed on select columns', path: ['options'] }, +] + export const tableScopeSchema = z.enum(['active', 'archived', 'all']) export const tableIdParamsSchema = z.object({ @@ -78,16 +110,19 @@ export const getTableQuerySchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), }) -export const tableColumnSchema = z.object({ - /** Stable column id (server-assigned). Absent on legacy/ pre-backfill columns. */ - id: z.string().optional(), - name: columnNameSchema, - type: columnTypeSchema, - required: z.boolean().optional().default(false), - unique: z.boolean().optional().default(false), - /** Set when the column is a workflow group's output. */ - workflowGroupId: z.string().optional(), -}) +export const tableColumnSchema = z + .object({ + /** Stable column id (server-assigned). Absent on legacy/ pre-backfill columns. */ + id: z.string().optional(), + name: columnNameSchema, + type: columnTypeSchema, + required: z.boolean().optional().default(false), + unique: z.boolean().optional().default(false), + options: columnOptionsSchema.optional(), + /** Set when the column is a workflow group's output. */ + workflowGroupId: z.string().optional(), + }) + .refine(...columnOptionsTypeRefine) export const createTableBodySchema = z.object({ name: tableNameSchema, @@ -112,27 +147,42 @@ export const renameTableBodySchema = z.object({ export const createTableColumnBodySchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), - column: z.object({ - // Optional stable id — first-party undo of a delete re-creates the column - // with its original id so saved (id-keyed) cell data restores correctly. - id: z.string().optional(), - name: columnNameSchema, - type: columnTypeSchema, - required: z.boolean().optional(), - unique: z.boolean().optional(), - position: z.number().int().min(0).optional(), - }), + column: z + .object({ + // Optional stable id — first-party undo of a delete re-creates the column + // with its original id so saved (id-keyed) cell data restores correctly. + id: z.string().optional(), + name: columnNameSchema, + type: columnTypeSchema, + required: z.boolean().optional(), + unique: z.boolean().optional(), + options: columnOptionsSchema.optional(), + position: z.number().int().min(0).optional(), + }) + .refine(...columnOptionsTypeRefine), }) export const updateTableColumnBodySchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), columnName: columnNameSchema, - updates: z.object({ - name: columnNameSchema.optional(), - type: columnTypeSchema.optional(), - required: z.boolean().optional(), - unique: z.boolean().optional(), - }), + updates: z + .object({ + name: columnNameSchema.optional(), + type: columnTypeSchema.optional(), + required: z.boolean().optional(), + unique: z.boolean().optional(), + /** Full replacement option set for a `select` column; the server rejects + * options on any other column type. */ + options: columnOptionsSchema.optional(), + }) + .refine( + (updates) => + updates.options === undefined || updates.type === undefined || updates.type === 'select', + { + message: 'options can only be set when the column type is (or is being changed to) select', + path: ['options'], + } + ), }) export const deleteTableColumnBodySchema = z.object({ diff --git a/apps/sim/lib/table/__tests__/sql.test.ts b/apps/sim/lib/table/__tests__/sql.test.ts index ca293848779..a257d0a0fc4 100644 --- a/apps/sim/lib/table/__tests__/sql.test.ts +++ b/apps/sim/lib/table/__tests__/sql.test.ts @@ -97,6 +97,13 @@ describe('SQL Builder', () => { expect(out).not.toContain('::timestamp') }) + it('emits ::numeric cast for $gte on a currency column', () => { + const cols: ColumnDefinition[] = [{ name: 'price', type: 'currency' }] + const out = render(buildFilterClause({ price: { $gte: 10 } }, TABLE, cols)) + expect(out).toContain(`(${TABLE}.data->>'price')::numeric >= `) + expect(out).not.toContain('::timestamp') + }) + it('falls back to ::numeric when column type is unknown', () => { const out = render(buildFilterClause({ score: { $gte: 5 } }, TABLE, NO_COLUMNS)) expect(out).toContain(`(${TABLE}.data->>'score')::numeric >= `) @@ -390,6 +397,24 @@ describe('SQL Builder', () => { expect(out).toBe(`(${TABLE}.data->>'birthDate')::timestamptz ASC NULLS LAST`) }) + it.each(['currency', 'percent', 'rating'] as const)( + 'sorts number-backed %s columns with ::numeric NULLS LAST', + (type) => { + const cols: ColumnDefinition[] = [{ name: 'amount', type }] + const out = render(buildSortClause({ amount: 'desc' }, TABLE, cols)) + expect(out).toBe(`(${TABLE}.data->>'amount')::numeric DESC NULLS LAST`) + } + ) + + it.each(['select', 'url', 'email', 'phone'] as const)( + 'sorts string-backed %s columns as text (no cast)', + (type) => { + const cols: ColumnDefinition[] = [{ name: 'field', type }] + const out = render(buildSortClause({ field: 'asc' }, TABLE, cols)) + expect(out).toBe(`${TABLE}.data->>'field' ASC`) + } + ) + it('sorts createdAt / updatedAt as direct column refs', () => { expect(render(buildSortClause({ createdAt: 'desc' }, TABLE, NO_COLUMNS))).toBe( `${TABLE}.createdAt DESC` diff --git a/apps/sim/lib/table/__tests__/validation.test.ts b/apps/sim/lib/table/__tests__/validation.test.ts index 3c9a139f7a8..8a5c89d7ed3 100644 --- a/apps/sim/lib/table/__tests__/validation.test.ts +++ b/apps/sim/lib/table/__tests__/validation.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { TABLE_LIMITS } from '../constants' +import { COLUMN_TYPES, TABLE_LIMITS } from '../constants' import { type ColumnDefinition, coerceRowToSchema, @@ -10,6 +10,7 @@ import { getUniqueColumns, type TableSchema, validateColumnDefinition, + validateColumnOptions, validateRowAgainstSchema, validateRowSize, validateTableName, @@ -79,14 +80,31 @@ describe('Validation', () => { }) it('should accept all valid column types', () => { - const types = ['string', 'number', 'boolean', 'date', 'json'] as const - - for (const type of types) { + for (const type of COLUMN_TYPES) { const result = validateColumnDefinition({ name: 'test', type }) expect(result.valid).toBe(true) } }) + it('should accept options on a select column', () => { + const result = validateColumnDefinition({ + name: 'status', + type: 'select', + options: ['Open', 'Closed'], + }) + expect(result.valid).toBe(true) + }) + + it('should reject options on a non-select column', () => { + const result = validateColumnDefinition({ + name: 'name', + type: 'string', + options: ['a'], + }) + expect(result.valid).toBe(false) + expect(result.errors[0]).toContain('only select columns take options') + }) + it('should reject empty column name', () => { const result = validateColumnDefinition({ name: '', type: 'string' }) expect(result.valid).toBe(false) @@ -110,6 +128,46 @@ describe('Validation', () => { }) }) + describe('validateColumnOptions', () => { + it('should accept bounded unique options on a select column', () => { + const result = validateColumnOptions(['Open', 'In progress', 'Closed'], 'status', 'select') + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + }) + + it('should reject options on a non-select column type', () => { + const result = validateColumnOptions(['a'], 'name', 'string') + expect(result.valid).toBe(false) + expect(result.errors[0]).toContain('only select columns take options') + }) + + it('should reject more than the maximum number of options', () => { + const options = Array.from({ length: TABLE_LIMITS.MAX_SELECT_OPTIONS + 1 }, (_, i) => `o${i}`) + const result = validateColumnOptions(options, 'status', 'select') + expect(result.valid).toBe(false) + expect(result.errors[0]).toContain('exceeds maximum options') + }) + + it('should reject empty options', () => { + const result = validateColumnOptions(['Open', ' '], 'status', 'select') + expect(result.valid).toBe(false) + expect(result.errors[0]).toContain('non-empty strings') + }) + + it('should reject options exceeding max length', () => { + const long = 'a'.repeat(TABLE_LIMITS.MAX_SELECT_OPTION_LENGTH + 1) + const result = validateColumnOptions([long], 'status', 'select') + expect(result.valid).toBe(false) + expect(result.errors[0]).toContain('exceeds maximum length') + }) + + it('should reject case-insensitive duplicate options', () => { + const result = validateColumnOptions(['Open', 'open'], 'status', 'select') + expect(result.valid).toBe(false) + expect(result.errors[0]).toContain('duplicate option') + }) + }) + describe('validateTableSchema', () => { it('should accept valid schema', () => { const schema: TableSchema = { @@ -277,6 +335,73 @@ describe('Validation', () => { expect(result.valid).toBe(false) expect(result.errors[0]).toContain('exceeds max string length') }) + + describe('rich column types', () => { + const richSchema: TableSchema = { + columns: [ + { name: 'website', type: 'url' }, + { name: 'contact', type: 'email' }, + { name: 'mobile', type: 'phone' }, + { name: 'status', type: 'select', options: ['Open', 'Closed'] }, + { name: 'price', type: 'currency' }, + { name: 'progress', type: 'percent' }, + { name: 'score', type: 'rating' }, + ], + } + + it('should validate string-backed rich types as strings', () => { + const result = validateRowAgainstSchema( + { + website: 'https://sim.ai', + contact: 'team@sim.ai', + mobile: '+1 (555) 010-0000', + status: 'Open', + }, + richSchema + ) + expect(result.valid).toBe(true) + }) + + it('should reject non-string values for string-backed rich types', () => { + const result = validateRowAgainstSchema({ website: 42 }, richSchema) + expect(result.valid).toBe(false) + expect(result.errors[0]).toContain('must be string') + }) + + it('should accept select values outside the predefined options', () => { + const result = validateRowAgainstSchema({ status: 'Archived' }, richSchema) + expect(result.valid).toBe(true) + }) + + it('should validate number-backed rich types as numbers', () => { + const result = validateRowAgainstSchema( + { price: 19.99, progress: 75, score: 4 }, + richSchema + ) + expect(result.valid).toBe(true) + }) + + it('should reject non-numeric values for number-backed rich types', () => { + const result = validateRowAgainstSchema({ price: 'expensive' }, richSchema) + expect(result.valid).toBe(false) + expect(result.errors[0]).toContain('must be number') + }) + + it('should round and clamp rating values to 0..RATING_MAX on coercion', () => { + const data = { score: 9.6, price: 9.6 } + coerceRowValues(data, richSchema) + expect(data.score).toBe(5) + expect(data.price).toBe(9.6) + + const low = { score: -3 } + coerceRowValues(low, richSchema) + expect(low.score).toBe(0) + + const fractional = { score: '3.4' } + coerceRowValues(fractional, richSchema) + expect(fractional.score).toBe(3) + }) + }) }) describe('coerceRowToSchema', () => { diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index 0a276f68a41..61ac2adf656 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -35,6 +35,10 @@ export const TABLE_LIMITS = { EXPORT_ASYNC_THRESHOLD_ROWS: 10000, /** Cap on the exclusion set ("select all, minus these") sent to an async delete job. */ MAX_EXCLUDE_ROW_IDS: 10000, + /** Maximum predefined options on a `select` column. */ + MAX_SELECT_OPTIONS: 100, + /** Maximum length of a single `select` column option. */ + MAX_SELECT_OPTION_LENGTH: 100, } as const /** @@ -111,7 +115,66 @@ export function getTablePlanLimits(): TablePlanLimitsByPlan { } } -export const COLUMN_TYPES = ['string', 'number', 'boolean', 'date', 'json'] as const +/** + * All column types a table column can declare. The first five are the storage + * primitives; the rest are rich display types that persist as one of those + * primitives (see {@link COLUMN_TYPE_STORAGE}) but render and edit with a + * richer UI — e.g. `url` is a string under the hood shown as a favicon link, + * `rating` is a number shown as stars. + */ +export const COLUMN_TYPES = [ + 'string', + 'number', + 'boolean', + 'date', + 'json', + 'select', + 'url', + 'email', + 'phone', + 'currency', + 'percent', + 'rating', +] as const + +/** Ratings range 0..RATING_MAX; `rating` cells render as RATING_MAX stars. */ +export const RATING_MAX = 5 + +/** Storage primitives — the value shapes actually persisted in row data. */ +export const STORAGE_COLUMN_TYPES = ['string', 'number', 'boolean', 'date', 'json'] as const + +export type StorageColumnType = (typeof STORAGE_COLUMN_TYPES)[number] + +/** + * Maps every column type to the storage primitive its values persist as. + * Validation, coercion, SQL casts, and type-change compatibility all key off + * the primitive so rich types behave exactly like their underlying primitive + * everywhere except display/editing. + */ +export const COLUMN_TYPE_STORAGE: Record<(typeof COLUMN_TYPES)[number], StorageColumnType> = { + string: 'string', + number: 'number', + boolean: 'boolean', + date: 'date', + json: 'json', + select: 'string', + url: 'string', + email: 'string', + phone: 'string', + currency: 'number', + percent: 'number', + rating: 'number', +} + +/** + * Resolves a column type to its storage primitive. Accepts any string so + * call sites holding a possibly-stale or unknown type (legacy schemas, UI + * display columns) degrade to `'string'` — the most permissive primitive — + * instead of throwing. + */ +export function getColumnStorageType(type: string | undefined): StorageColumnType { + return COLUMN_TYPE_STORAGE[type as (typeof COLUMN_TYPES)[number]] ?? 'string' +} export const NAME_PATTERN = /^[a-z_][a-z0-9_]*$/i diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts index d25ee031e0e..15fd3b9fc1d 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -276,6 +276,29 @@ describe('import', () => { ) expect(rows).toEqual([{ name: 'Alice' }]) }) + + it('coerces rich column types as their storage primitive', () => { + const richSchema: TableSchema = { + columns: [ + { name: 'website', type: 'url' }, + { name: 'status', type: 'select', options: ['Open', 'Closed'] }, + { name: 'price', type: 'currency' }, + { name: 'score', type: 'rating' }, + ], + } + const rows = coerceRowsForTable( + [{ website: 'https://sim.ai', status: 'Open', price: '19.99', score: '4' }], + richSchema, + new Map([ + ['website', 'website'], + ['status', 'status'], + ['price', 'price'], + ['score', 'score'], + ]) + ) + + expect(rows).toEqual([{ website: 'https://sim.ai', status: 'Open', price: 19.99, score: 4 }]) + }) }) describe('createCsvParser', () => { diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index a132ba96dd7..0b970dd0c59 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -13,6 +13,7 @@ import { type Options as CsvParseOptions, type Parser, parse as parseCsvStream } from 'csv-parse' import { getColumnId } from '@/lib/table/column-keys' +import { getColumnStorageType, type StorageColumnType } from '@/lib/table/constants' import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types' /** @@ -44,9 +45,6 @@ export function createCsvParser(delimiter = ','): Parser { return parseCsvStream(csvParseOptions(delimiter)) } -/** Narrower type than `COLUMN_TYPES` used internally for coercion. */ -export type CsvColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json' - /** Number of CSV rows sampled when inferring column types for a new table. */ export const CSV_SCHEMA_SAMPLE_SIZE = 100 @@ -132,7 +130,7 @@ export async function parseCsvBuffer( * prefer narrower types (number > boolean > ISO date) and fall back to string. * JSON is never inferred automatically. */ -export function inferColumnType(values: unknown[]): Exclude { +export function inferColumnType(values: unknown[]): Exclude { const nonEmpty = values.filter((v) => v !== null && v !== undefined && v !== '') if (nonEmpty.length === 0) return 'string' @@ -218,7 +216,7 @@ export function inferSchemaFromCsv( */ export function coerceValue( value: unknown, - colType: CsvColumnType + colType: StorageColumnType ): string | number | boolean | null | Record | unknown[] { if (value === null || value === undefined || value === '') return null switch (colType) { @@ -418,8 +416,10 @@ export function coerceRowsForTable( if (!colName) continue const col = colByName.get(colName) if (!col) continue - const colType = (col.type as CsvColumnType) ?? 'string' - coerced[getColumnId(col)] = coerceValue(value, colType) as RowData[string] + coerced[getColumnId(col)] = coerceValue( + value, + getColumnStorageType(col.type) + ) as RowData[string] } return coerced }) diff --git a/apps/sim/lib/table/llm/enrichment.ts b/apps/sim/lib/table/llm/enrichment.ts index 98e13bfd286..bffdb8dbf3b 100644 --- a/apps/sim/lib/table/llm/enrichment.ts +++ b/apps/sim/lib/table/llm/enrichment.ts @@ -5,8 +5,17 @@ * with table-specific information so LLMs can construct proper queries. */ +import { getColumnStorageType } from '../constants' import type { TableSummary } from '../types' +/** Example cell value for a column type, used in LLM-facing data examples. */ +function exampleValueForType(type: string): unknown { + const storageType = getColumnStorageType(type) + if (storageType === 'number') return 123 + if (storageType === 'boolean') return true + return 'example' +} + /** * Operations that use filters and need filter-specific enrichment. */ @@ -41,8 +50,8 @@ export function enrichTableToolDescription( const columnList = table.columns.map((col) => ` - ${col.name} (${col.type})`).join('\n') if (FILTER_OPERATIONS.has(toolId)) { - const stringCols = table.columns.filter((c) => c.type === 'string') - const numberCols = table.columns.filter((c) => c.type === 'number') + const stringCols = table.columns.filter((c) => getColumnStorageType(c.type) === 'string') + const numberCols = table.columns.filter((c) => getColumnStorageType(c.type) === 'number') let filterExample = '' if (stringCols.length > 0 && numberCols.length > 0) { @@ -91,7 +100,7 @@ ${filterExample}${sortExample}` const exampleCols = table.columns.slice(0, 3) const dataExample = exampleCols.reduce( (obj, col) => { - obj[col.name] = col.type === 'number' ? 123 : col.type === 'boolean' ? true : 'example' + obj[col.name] = exampleValueForType(col.type) return obj }, {} as Record @@ -168,7 +177,7 @@ export function enrichTableToolParameters( const exampleCols = table.columns.slice(0, 2) const exampleData = exampleCols.reduce( (obj: Record, col: { name: string; type: string }) => { - obj[col.name] = col.type === 'number' ? 123 : col.type === 'boolean' ? true : 'value' + obj[col.name] = exampleValueForType(col.type) return obj }, {} as Record diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 2c526fa9b2d..97a9aa86022 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -12,6 +12,7 @@ import { tableJobs, tableRowExecutions, userTableDefinitions, userTableRows } fr import { createLogger } from '@sim/logger' import { getPostgresErrorCode, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { omit } from '@sim/utils/object' import { and, asc, @@ -40,7 +41,13 @@ import { remapGroupColumnRefs, withGeneratedColumnIds, } from './column-keys' -import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from './constants' +import { + COLUMN_TYPES, + getColumnStorageType, + NAME_PATTERN, + TABLE_LIMITS, + USER_TABLE_ROWS_SQL_NAME, +} from './constants' import { areGroupDepsSatisfied } from './deps' import { CSV_MAX_BATCH_SIZE } from './import' import { keyBetween, nKeysBetween } from './order-key' @@ -79,6 +86,7 @@ import type { TableRow, TableSchema, UpdateColumnConstraintsData, + UpdateColumnOptionsData, UpdateColumnTypeData, UpdateRowData, UpdateWorkflowGroupData, @@ -93,6 +101,7 @@ import { coerceRowToSchema, coerceRowValues, getUniqueColumns, + validateColumnOptions, validateRowSize, validateTableName, validateTableSchema, @@ -642,6 +651,7 @@ export async function addTableColumn( type: string required?: boolean unique?: boolean + options?: string[] position?: number }, requestId: string @@ -665,6 +675,17 @@ export async function addTableColumn( ) } + if (column.options !== undefined) { + const optionsValidation = validateColumnOptions( + column.options, + column.name, + column.type as (typeof COLUMN_TYPES)[number] + ) + if (!optionsValidation.valid) { + throw new Error(`Invalid column options: ${optionsValidation.errors.join('; ')}`) + } + } + const schema = table.schema if (schema.columns.some((c) => c.name.toLowerCase() === column.name.toLowerCase())) { throw new Error(`Column "${column.name}" already exists`) @@ -684,6 +705,7 @@ export async function addTableColumn( type: column.type as TableSchema['columns'][number]['type'], required: column.required ?? false, unique: column.unique ?? false, + ...(column.options !== undefined ? { options: column.options } : {}), } const newColumnId = getColumnId(newColumn) @@ -4281,42 +4303,98 @@ export async function updateColumnType( } const column = schema.columns[columnIndex] - if (column.type === data.newType) { + if (column.type === data.newType && data.options === undefined) { return table } + + if (data.options !== undefined) { + const optionsValidation = validateColumnOptions(data.options, column.name, data.newType) + if (!optionsValidation.valid) { + throw new Error(`Invalid column options: ${optionsValidation.errors.join('; ')}`) + } + } + const columnKey = getColumnId(column) - // Validate existing data is compatible with the new type - const rows = await trx - .select({ id: userTableRows.id, data: userTableRows.data }) - .from(userTableRows) - .where( - and( - eq(userTableRows.tableId, data.tableId), - sql`${userTableRows.data} ? ${columnKey}`, - sql`${userTableRows.data}->>${columnKey}::text IS NOT NULL` + if (column.type !== data.newType) { + // Validate existing data is compatible with the new type + const rows = await trx + .select({ id: userTableRows.id, data: userTableRows.data }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, data.tableId), + sql`${userTableRows.data} ? ${columnKey}`, + sql`${userTableRows.data}->>${columnKey}::text IS NOT NULL` + ) ) - ) - let incompatibleCount = 0 - for (const row of rows) { - const rowData = row.data as RowData - const value = rowData[columnKey] - if (value === null || value === undefined) continue + let incompatibleCount = 0 + for (const row of rows) { + const rowData = row.data as RowData + const value = rowData[columnKey] + if (value === null || value === undefined) continue - if (!isValueCompatibleWithType(value, data.newType)) { - incompatibleCount++ + if (!isValueCompatibleWithType(value, data.newType)) { + incompatibleCount++ + } + } + + if (incompatibleCount > 0) { + throw new Error( + `Cannot change column "${column.name}" to type "${data.newType}": ${incompatibleCount} row(s) have incompatible values. Fix or remove the incompatible values first.` + ) } } - if (incompatibleCount > 0) { - throw new Error( - `Cannot change column "${column.name}" to type "${data.newType}": ${incompatibleCount} row(s) have incompatible values. Fix or remove the incompatible values first.` - ) + const updatedColumns = schema.columns.map((c, i) => { + if (i !== columnIndex) return c + const next = { ...c, type: data.newType } + if (data.newType !== 'select') return omit(next, ['options']) + return data.options !== undefined ? { ...next, options: data.options } : next + }) + const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } + const now = new Date() + + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, updatedAt: now }) + .where(eq(userTableDefinitions.id, data.tableId)) + + logger.info( + `[${requestId}] Changed column "${column.name}" type from "${column.type}" to "${data.newType}" in table ${data.tableId}` + ) + + return { ...table, schema: updatedSchema, updatedAt: now } + }) +} + +/** + * Replaces the predefined options of a `select` column. Pure schema metadata — + * existing cell values are never rewritten, since option membership is a soft + * constraint (values outside the list stay valid and render as plain tags). + * + * @throws Error if table/column not found or the column is not a select column + */ +export async function updateColumnOptions( + data: UpdateColumnOptionsData, + requestId: string +): Promise { + return withLockedTable(data.tableId, async (table, trx) => { + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) + if (columnIndex === -1) { + throw new Error(`Column "${data.columnName}" not found`) + } + + const column = schema.columns[columnIndex] + const validation = validateColumnOptions(data.options, column.name, column.type) + if (!validation.valid) { + throw new Error(`Invalid column options: ${validation.errors.join('; ')}`) } const updatedColumns = schema.columns.map((c, i) => - i === columnIndex ? { ...c, type: data.newType } : c + i === columnIndex ? { ...c, options: data.options } : c ) const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } const now = new Date() @@ -4327,7 +4405,7 @@ export async function updateColumnType( .where(eq(userTableDefinitions.id, data.tableId)) logger.info( - `[${requestId}] Changed column "${column.name}" type from "${column.type}" to "${data.newType}" in table ${data.tableId}` + `[${requestId}] Updated options for column "${column.name}" in table ${data.tableId}` ) return { ...table, schema: updatedSchema, updatedAt: now } @@ -5286,7 +5364,7 @@ function isValueCompatibleWithType( ): boolean { if (value === null || value === undefined) return true - switch (targetType) { + switch (getColumnStorageType(targetType)) { case 'string': return true case 'number': { diff --git a/apps/sim/lib/table/sql.ts b/apps/sim/lib/table/sql.ts index 7e064468a26..5981b6feb24 100644 --- a/apps/sim/lib/table/sql.ts +++ b/apps/sim/lib/table/sql.ts @@ -8,7 +8,7 @@ import type { SQL } from 'drizzle-orm' import { sql } from 'drizzle-orm' import { getColumnId } from './column-keys' -import { NAME_PATTERN } from './constants' +import { getColumnStorageType, NAME_PATTERN } from './constants' import type { ColumnDefinition, ConditionOperators, Filter, JsonValue, Sort } from './types' /** @@ -27,12 +27,15 @@ type ColumnTypeMap = ReadonlyMap /** * Returns the Postgres cast needed to compare a JSONB text value of the given - * column type, or `null` when text comparison is correct. Single source of - * truth for both filter range operators and sort ordering — keeps the two - * paths from drifting apart. + * column type, or `null` when text comparison is correct. Keys off the storage + * primitive so number-backed rich types (currency, percent, rating) compare + * numerically and string-backed ones (url, email, …) compare as text. Single + * source of truth for both filter range operators and sort ordering — keeps + * the two paths from drifting apart. */ function jsonbCastForType(type: ColumnType | undefined): 'numeric' | 'timestamptz' | null { - switch (type) { + if (type === undefined) return null + switch (getColumnStorageType(type)) { case 'number': return 'numeric' case 'date': diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index aaa0760a3e3..1038ed0f544 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -40,6 +40,13 @@ export interface ColumnDefinition { type: (typeof COLUMN_TYPES)[number] required?: boolean unique?: boolean + /** + * Predefined choices for `select` columns; ignored on every other type. + * Membership is a soft constraint — cell values outside this list stay + * valid (so removing an option never strands row data) and render as an + * uncolored tag. Bounded by `TABLE_LIMITS.MAX_SELECT_OPTIONS`. + */ + options?: string[] /** * When set, this column is one of a workflow group's outputs. The value in * `row.data[getColumnId(col)]` is populated by the group's per-cell run. @@ -552,6 +559,18 @@ export interface UpdateColumnTypeData { tableId: string columnName: string newType: (typeof COLUMN_TYPES)[number] + /** + * Replacement option set applied atomically with the type change (same + * locked transaction). Only valid when `newType` is `select`. + */ + options?: string[] +} + +export interface UpdateColumnOptionsData { + tableId: string + columnName: string + /** Full replacement set of predefined options for a `select` column. */ + options: string[] } export interface UpdateColumnConstraintsData { diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index fa3aacaf98b..7f0ba63f602 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -7,7 +7,13 @@ import { userTableRows } from '@sim/db/schema' import { and, eq, or, type SQL, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getColumnId } from './column-keys' -import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from './constants' +import { + COLUMN_TYPES, + getColumnStorageType, + NAME_PATTERN, + RATING_MAX, + TABLE_LIMITS, +} from './constants' import { withSeqscanOff } from './planner' import type { ColumnDefinition, JsonValue, RowData, TableSchema, ValidationResult } from './types' @@ -204,7 +210,12 @@ export function validateTableSchema(schema: TableSchema): ValidationResult { return { valid: errors.length === 0, errors } } -/** Validates row data matches schema column types and required fields. */ +/** + * Validates row data matches schema column types and required fields. Rich + * column types (url, select, currency, …) validate as their storage primitive + * — formatting is a display concern, so e.g. an `email` cell only has to be a + * string, not a syntactically valid address. + */ export function validateRowAgainstSchema(data: RowData, schema: TableSchema): ValidationResult { const errors: string[] = [] @@ -218,7 +229,7 @@ export function validateRowAgainstSchema(data: RowData, schema: TableSchema): Va if (value === null || value === undefined) continue - switch (column.type) { + switch (getColumnStorageType(column.type)) { case 'string': if (typeof value !== 'string') { errors.push(`${column.name} must be string, got ${typeof value}`) @@ -267,22 +278,28 @@ function coerceValueToColumnType( value: JsonValue, type: ColumnDefinition['type'] ): { ok: true; value: JsonValue } | { ok: false } { - switch (type) { + switch (getColumnStorageType(type)) { case 'string': if (typeof value === 'string') return { ok: true, value } if (typeof value === 'number' || typeof value === 'boolean') { return { ok: true, value: String(value) } } return { ok: false } - case 'number': - if (typeof value === 'number') { - return Number.isFinite(value) ? { ok: true, value } : { ok: false } + case 'number': { + const num = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() !== '' + ? Number(value) + : Number.NaN + if (!Number.isFinite(num)) return { ok: false } + // Ratings normalize to whole stars in 0..RATING_MAX on every write path + // (API, batch, CSV import) so storage always matches what the grid renders. + if (type === 'rating') { + return { ok: true, value: Math.min(RATING_MAX, Math.max(0, Math.round(num))) } } - if (typeof value === 'string' && value.trim() !== '') { - const parsed = Number(value) - return Number.isFinite(parsed) ? { ok: true, value: parsed } : { ok: false } - } - return { ok: false } + return { ok: true, value: num } + } case 'boolean': if (typeof value === 'boolean') return { ok: true, value } if (typeof value === 'string') { @@ -587,7 +604,7 @@ export async function checkBatchUniqueConstraintsDb( const valueConditions = valueArray.map((normalizedValue) => { // Check if the original values are strings (normalized values for strings are lowercase) // We need to determine the type from the column definition or the first row that has this value - const isStringColumn = column.type === 'string' + const isStringColumn = getColumnStorageType(column.type) === 'string' if (isStringColumn) { return sql`lower(${userTableRows.data}->>${sql.raw(`'${columnId}'`)}) = ${normalizedValue}` @@ -680,5 +697,56 @@ export function validateColumnDefinition(column: ColumnDefinition): ValidationRe ) } + if (column.options !== undefined) { + errors.push(...validateColumnOptions(column.options, column.name, column.type).errors) + } + + return { valid: errors.length === 0, errors } +} + +/** + * Validates the predefined options of a `select` column: select-only, every + * entry a bounded non-empty string, no case-insensitive duplicates. Option + * membership of cell values is intentionally NOT validated anywhere — options + * are a soft constraint so removing one never invalidates existing rows. + */ +export function validateColumnOptions( + options: string[], + columnName: string, + columnType: ColumnDefinition['type'] +): ValidationResult { + const errors: string[] = [] + + if (columnType !== 'select') { + errors.push( + `Column "${columnName}" has type "${columnType}" — only select columns take options` + ) + return { valid: false, errors } + } + + if (options.length > TABLE_LIMITS.MAX_SELECT_OPTIONS) { + errors.push( + `Column "${columnName}" exceeds maximum options (${TABLE_LIMITS.MAX_SELECT_OPTIONS})` + ) + } + + const seen = new Set() + for (const option of options) { + if (typeof option !== 'string' || option.trim() === '') { + errors.push(`Column "${columnName}" options must be non-empty strings`) + break + } + if (option.length > TABLE_LIMITS.MAX_SELECT_OPTION_LENGTH) { + errors.push( + `Column "${columnName}" option "${option}" exceeds maximum length (${TABLE_LIMITS.MAX_SELECT_OPTION_LENGTH} characters)` + ) + } + const normalized = option.toLowerCase() + if (seen.has(normalized)) { + errors.push(`Column "${columnName}" has duplicate option "${option}"`) + } + seen.add(normalized) + } + return { valid: errors.length === 0, errors } } diff --git a/apps/sim/stores/table/types.ts b/apps/sim/stores/table/types.ts index 17e72fb8c0a..1ff960dd736 100644 --- a/apps/sim/stores/table/types.ts +++ b/apps/sim/stores/table/types.ts @@ -57,6 +57,8 @@ export type TableUndoAction = columnPosition: number columnUnique: boolean columnRequired: boolean + /** Predefined options to restore on re-create (select columns). */ + columnOptions?: string[] cellData: Array<{ rowId: string; value: unknown }> previousOrder: string[] | null previousWidth: number | null @@ -69,6 +71,11 @@ export type TableUndoAction = columnName: string previousType: ColumnDefinition['type'] newType: ColumnDefinition['type'] + /** Options to restore when undoing back to a select column (the server + * strips them on the way out of select). */ + previousOptions?: string[] + /** Options to re-apply when redoing a change to a select column. */ + newOptions?: string[] } | { type: 'toggle-column-constraint'