diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index 7eecb5ee466..4b3af2a0693 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -15,8 +15,10 @@ import { deleteColumn, renameColumn, updateColumnConstraints, + updateColumnOptions, updateColumnType, } from '@/lib/table' +import { columnMatchesRef } from '@/lib/table/column-keys' import { accessError, checkAccess, normalizeColumn, rootErrorMessage } from '@/app/api/table/utils' const logger = createLogger('TableColumnsAPI') @@ -68,7 +70,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum msg.includes('already exists') || msg.includes('maximum column') || msg.includes('Invalid column') || - msg.includes('exceeds maximum') + msg.includes('exceeds maximum') || + msg.includes('option') ) { return NextResponse.json({ error: msg }, { status: 400 }) } @@ -116,9 +119,34 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu ) } - if (updates.type) { + // A payload that repeats the current type must not go through + // `updateColumnType` — it early-returns on an unchanged type and would drop + // any `options` alongside it. Only a real type change routes there; an + // unchanged type with options routes to the options-only update. + const currentColumn = table.schema.columns.find((c) => + columnMatchesRef(c, validated.columnName) + ) + const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type + + if (typeChanging) { updatedTable = await updateColumnType( - { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, + { + tableId, + columnName: updates.name ?? validated.columnName, + newType: updates.type as NonNullable, + ...(updates.options !== undefined ? { options: updates.options } : {}), + ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), + }, + requestId + ) + } else if (updates.options !== undefined || updates.multiple !== undefined) { + updatedTable = await updateColumnOptions( + { + tableId, + columnName: updates.name ?? validated.columnName, + options: updates.options ?? currentColumn?.options ?? [], + ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), + }, requestId ) } @@ -162,7 +190,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu msg.includes('Invalid column') || msg.includes('exceeds maximum') || msg.includes('incompatible') || - msg.includes('duplicate') + msg.includes('duplicate') || + msg.includes('option') ) { return NextResponse.json({ error: msg }, { status: 400 }) } diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 7424258ad0e..0b2c07ba1f5 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -279,5 +279,7 @@ export function normalizeColumn(col: ColumnDefinition): ColumnDefinition { required: col.required ?? false, unique: col.unique ?? false, ...(col.workflowGroupId ? { workflowGroupId: col.workflowGroupId } : {}), + ...(col.options ? { options: col.options } : {}), + ...(col.multiple ? { multiple: true } : {}), } } 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..d751a883d14 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -14,8 +14,10 @@ import { deleteColumn, renameColumn, updateColumnConstraints, + updateColumnOptions, updateColumnType, } from '@/lib/table' +import { columnMatchesRef } from '@/lib/table/column-keys' import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' import { checkRateLimit, @@ -138,9 +140,34 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu ) } - if (updates.type) { + // A payload that repeats the current type must not go through + // `updateColumnType` — it early-returns on an unchanged type and would drop + // any `options` alongside it. Only a real type change routes there; an + // unchanged type with options routes to the options-only update. + const currentColumn = table.schema.columns.find((c) => + columnMatchesRef(c, validated.columnName) + ) + const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type + + if (typeChanging) { updatedTable = await updateColumnType( - { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, + { + tableId, + columnName: updates.name ?? validated.columnName, + newType: updates.type as NonNullable, + ...(updates.options !== undefined ? { options: updates.options } : {}), + ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), + }, + requestId + ) + } else if (updates.options !== undefined || updates.multiple !== undefined) { + updatedTable = await updateColumnOptions( + { + tableId, + columnName: updates.name ?? validated.columnName, + options: updates.options ?? currentColumn?.options ?? [], + ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), + }, requestId ) } @@ -195,7 +222,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu msg.includes('Invalid column') || msg.includes('exceeds maximum') || msg.includes('incompatible') || - msg.includes('duplicate') + msg.includes('duplicate') || + msg.includes('option') ) { return NextResponse.json({ error: msg }, { 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 0f21afa12cc..be77db90ad6 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 @@ -5,14 +5,24 @@ import { Button, ChipCombobox, ChipInput, cn, FieldDivider, Label, Switch, toast import { X } from '@sim/emcn/icons' import { toError } from '@sim/utils/errors' import { findValidationIssue, isValidationError } from '@/lib/api/client/errors' -import type { ColumnDefinition } from '@/lib/table' +import type { ColumnDefinition, SelectOption } from '@/lib/table' import { FieldError, RequiredLabel, } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields' import { useAddTableColumn, useUpdateColumn } from '@/hooks/queries/tables' +import { SelectOptionsEditor } from '../select-field' import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types' +/** Whether a column type carries an option set. */ +function isSelectType(type: ColumnDefinition['type']): boolean { + return type === 'select' +} + +function optionsEqual(a: SelectOption[], b: SelectOption[]): boolean { + return JSON.stringify(a) === JSON.stringify(b) +} + /** * Discriminates the two flows the column-config sidebar handles. Workflow * configuration is a separate component (``) so this surface @@ -94,11 +104,30 @@ function ColumnConfigBody({ const [uniqueInput, setUniqueInput] = useState(() => config.mode === 'edit' ? !!existingColumn?.unique : false ) + const [optionsInput, setOptionsInput] = useState(() => + config.mode === 'edit' ? (existingColumn?.options ?? []) : [] + ) + const [multipleInput, setMultipleInput] = useState(() => + config.mode === 'edit' ? !!existingColumn?.multiple : false + ) const [showValidation, setShowValidation] = useState(false) const [nameError, setNameError] = useState(null) + const [optionsError, setOptionsError] = useState(null) const saveDisabled = updateColumn.isPending || addColumn.isPending const trimmedName = nameInput.trim() + const wantsOptions = isSelectType(typeInput) + const trimmedOptions = optionsInput.map((o) => ({ ...o, name: o.name.trim() })) + + /** Client-side option validation mirroring the server rules; returns an error message or null. */ + function validateOptions(): string | null { + if (!wantsOptions) return null + if (trimmedOptions.length === 0) return 'Add at least one option' + if (trimmedOptions.some((o) => !o.name)) return 'Option names cannot be empty' + const names = trimmedOptions.map((o) => o.name.toLowerCase()) + if (new Set(names).size !== names.length) return 'Option names must be unique' + return null + } async function handleSave() { if (!trimmedName) { @@ -106,12 +135,21 @@ function ColumnConfigBody({ return } + const optionsIssue = validateOptions() + if (optionsIssue) { + setOptionsError(optionsIssue) + return + } + try { if (config.mode === 'create') { await addColumn.mutateAsync({ name: trimmedName, type: typeInput, - ...(uniqueInput ? { unique: true } : {}), + // Select columns don't expose a unique constraint. + ...(!wantsOptions && uniqueInput ? { unique: true } : {}), + ...(wantsOptions ? { options: trimmedOptions } : {}), + ...(wantsOptions && multipleInput ? { multiple: true } : {}), }) toast.success(`Added "${trimmedName}"`) onClose() @@ -122,12 +160,24 @@ function ColumnConfigBody({ // name to detect an actual rename. const renamed = trimmedName !== (existingColumn?.name ?? config.columnName) const typeChanged = !!existingColumn && existingColumn.type !== typeInput - const uniqueChanged = !!existingColumn && !!existingColumn.unique !== uniqueInput + const uniqueChanged = + !wantsOptions && !!existingColumn && !!existingColumn.unique !== uniqueInput + const optionsChanged = + wantsOptions && !optionsEqual(existingColumn?.options ?? [], trimmedOptions) + const multipleChanged = wantsOptions && !!existingColumn?.multiple !== multipleInput - const updates: { name?: string; type?: ColumnDefinition['type']; unique?: boolean } = { + const updates: { + name?: string + type?: ColumnDefinition['type'] + unique?: boolean + options?: SelectOption[] + multiple?: boolean + } = { ...(renamed ? { name: trimmedName } : {}), ...(typeChanged ? { type: typeInput } : {}), ...(uniqueChanged ? { unique: uniqueInput } : {}), + ...(wantsOptions && (typeChanged || optionsChanged) ? { options: trimmedOptions } : {}), + ...(wantsOptions && (typeChanged || multipleChanged) ? { multiple: multipleInput } : {}), } if (Object.keys(updates).length === 0) { onClose() @@ -207,17 +257,48 @@ function ColumnConfigBody({ )} - -
-
- - setUniqueInput(!!v)} - /> -
-
+ {wantsOptions && ( + <> + +
+ Options + { + setOptionsInput(next) + if (optionsError) setOptionsError(null) + }} + /> + {optionsError && } +
+ +
+ + setMultipleInput(!!v)} + /> +
+ + )} + + {/* Select columns don't expose a unique constraint. */} + {!wantsOptions && ( + <> + +
+
+ + setUniqueInput(!!v)} + /> +
+
+ + )}
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 9b4a7af4790..6ca8023e80b 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 @@ -2,6 +2,7 @@ import type React from 'react' import { Calendar as CalendarIcon, PlayOutline, + TagIcon, TypeBoolean, TypeJson, TypeNumber, @@ -28,6 +29,7 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [ { type: 'boolean', label: 'Boolean', icon: TypeBoolean }, { type: 'date', label: 'Date', icon: CalendarIcon }, { type: 'json', label: 'JSON', icon: TypeJson }, + { type: 'select', label: 'Select', icon: TagIcon }, { 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 28c971bda9f..efee32d04e8 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 @@ -27,6 +27,7 @@ import { localPartsToDateValue, todayLocalCalendarDate, } from '../../utils' +import { SelectValueEditor } from '../select-field' const logger = createLogger('RowModal') @@ -275,6 +276,14 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) { ) } + if (column.type === 'select') { + return ( + + + + ) + } + return ( void +} + +/** + * Add/remove/rename the options of a `select` column. Option ids are stable + * across edits so existing cell data survives renames. New options are added by + * typing into the trailing empty row — the first keystroke materializes the + * option and focus jumps into it so typing flows straight through. Options + * default to the neutral `gray` pill (per-option colors aren't exposed yet; the + * `color` field stays in the model so a picker can be re-added later). + */ +export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorProps) { + const inputRefs = useRef>(new Map()) + const trailingRef = useRef(null) + const [pendingFocusId, setPendingFocusId] = useState(null) + + // Focus a freshly materialized option once it has rendered, cursor at end. + // The new row and `pendingFocusId` land in the same commit, so its ref is + // registered by the time this effect runs. + useEffect(() => { + if (!pendingFocusId) return + const el = inputRefs.current.get(pendingFocusId) + if (el) { + el.focus() + const end = el.value.length + el.setSelectionRange(end, end) + } + setPendingFocusId(null) + }, [pendingFocusId]) + + const update = (id: string, patch: Partial) => { + onChange(options.map((o) => (o.id === id ? { ...o, ...patch } : o))) + } + + const remove = (id: string) => { + inputRefs.current.delete(id) + onChange(options.filter((o) => o.id !== id)) + } + + /** Typing into the trailing row promotes it to a real option and keeps focus. */ + const materialize = (name: string) => { + const id = generateShortId() + onChange([...options, { id, name, color: 'gray' }]) + setPendingFocusId(id) + } + + return ( +
+ {options.map((option) => ( +
+ { + if (el) inputRefs.current.set(option.id, el) + else inputRefs.current.delete(option.id) + }} + value={option.name} + onChange={(e) => update(option.id, { name: e.target.value })} + onKeyDown={(e) => { + // Enter jumps to the trailing row so options can be added in a row. + if (e.key === 'Enter') { + e.preventDefault() + trailingRef.current?.focus() + } + }} + placeholder='Option name' + spellCheck={false} + autoComplete='off' + className='min-w-0 flex-1' + /> + +
+ ))} +
+ { + if (e.target.value) materialize(e.target.value) + }} + placeholder='Add option' + spellCheck={false} + autoComplete='off' + className='min-w-0 flex-1' + /> + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-pill.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-pill.tsx new file mode 100644 index 00000000000..4b76b0949e9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-pill.tsx @@ -0,0 +1,44 @@ +'use client' + +import { Badge, cn } from '@sim/emcn' +import type { ColumnDefinition, SelectOption } from '@/lib/table' + +/** Reads the raw stored option ids from a cell value (single string or array). */ +export function toSelectedIds(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string') + if (typeof value === 'string' && value !== '') return [value] + return [] +} + +/** + * Resolves a `select` cell's stored ids to their declared options, preserving + * selection order. An id with no matching option — stale after that option was + * deleted — is dropped, so the cell falls back to empty ("None") rather than + * showing an orphaned reference. + */ +export function resolveSelectOptions(column: ColumnDefinition, value: unknown): SelectOption[] { + const byId = new Map((column.options ?? []).map((o) => [o.id, o])) + return toSelectedIds(value) + .map((id) => byId.get(id)) + .filter((o): o is SelectOption => o != null) +} + +/** The still-valid option ids of a cell (orphaned/removed ids dropped). */ +export function selectedOptionIds(column: ColumnDefinition, value: unknown): string[] { + return resolveSelectOptions(column, value).map((o) => o.id) +} + +interface SelectPillProps { + option: SelectOption + size?: 'sm' | 'md' + className?: string +} + +/** A single colored option pill, rendered through the shared `Badge` palette. */ +export function SelectPill({ option, size = 'sm', className }: SelectPillProps) { + return ( + + {option.name} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-value-editor.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-value-editor.tsx new file mode 100644 index 00000000000..0a22baabcee --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-value-editor.tsx @@ -0,0 +1,83 @@ +'use client' + +import { useMemo } from 'react' +import { ChipDropdown } from '@sim/emcn' +import type { ColumnDefinition } from '@/lib/table' +import { SelectPill, selectedOptionIds } from './select-pill' + +interface SelectValueEditorProps { + column: ColumnDefinition + value: unknown + /** Single columns emit a string id or null; multiselect emits a string[]. */ + onChange: (next: string | string[] | null) => void + fullWidth?: boolean + align?: 'start' | 'center' | 'end' +} + +const CLEAR_VALUE = '' + +/** + * Option picker for `select`/`multiselect` cells in a form context (the row + * modal) — a `ChipDropdown` pill that lists each option as its colored pill and + * writes option ids back through `onChange`. Inline grid editing uses a bare + * `DropdownMenu` instead (see `InlineSelectEditor`). + */ +export function SelectValueEditor({ + column, + value, + onChange, + fullWidth, + align = 'start', +}: SelectValueEditorProps) { + const isMulti = !!column.multiple + const options = useMemo( + () => + (column.options ?? []).map((option) => ({ + value: option.id, + label: , + })), + [column.options] + ) + + if (isMulti) { + return ( + { + if (column.required && ids.length === 0) return + onChange(ids) + }} + options={options} + showAllOption={false} + placeholder='Select options' + align={align} + fullWidth={fullWidth} + matchTriggerWidth={false} + /> + ) + } + + // Offer a "None" entry to clear the cell — except on a required column, where + // clearing to null can never be committed (required validation rejects it). + const singleOptions = column.required + ? options + : [ + { value: CLEAR_VALUE, label: None }, + ...options, + ] + + return ( + onChange(id === CLEAR_VALUE ? null : id)} + options={singleOptions} + placeholder='Select an option' + align={align} + fullWidth={fullWidth} + matchTriggerWidth={false} + /> + ) +} 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 edc4a92302e..283dfa21d8c 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 @@ -5,9 +5,10 @@ import { useEffect, useRef, useState } from 'react' import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn' import { parse } from 'tldts' import { faviconUrl } from '@/lib/core/utils/favicon' -import type { RowExecutionMetadata } from '@/lib/table' +import type { RowExecutionMetadata, SelectOption } from '@/lib/table' import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils' import { storageToDisplay } from '../../../utils' +import { resolveSelectOptions, SelectPill } from '../../select-field' import type { DisplayColumn } from '../types' import { SimResourceCell, type SimResourceType } from './sim-resource-cell' @@ -25,6 +26,7 @@ export type CellRenderKind = | { kind: 'no-output' } // Plain typed cells | { kind: 'boolean'; checked: boolean } + | { kind: 'select'; options: SelectOption[] } | { kind: 'json'; text: string } | { kind: 'date'; text: string } | { kind: 'url'; text: string; href: string; domain: string } @@ -120,6 +122,11 @@ export function resolveCellRender({ } if (column.type === 'boolean') return { kind: 'boolean', checked: Boolean(value) } + // Always render select cells as the `select` kind — an empty one shows a muted + // "None" so every select cell reads as a clickable dropdown. + if (column.type === 'select') { + return { kind: 'select', options: resolveSelectOptions(column, 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) } @@ -346,6 +353,20 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
) + case 'select': + // Chip-only view: just the option pills. Pills stay visible while editing — + // the inline editor overlays an invisible trigger and portals its menu + // below, so the cell keeps showing the current selection. + return ( + + {kind.options.length > 0 ? ( + kind.options.map((option) => ) + ) : ( + None + )} + + ) + case 'json': return ( (() => selectedOptionIds(column, value)) + const [open, setOpen] = useState(true) + const latestRef = useRef(draft) + const doneRef = useRef(false) + const cancelledRef = useRef(false) + + const setDraftAnd = (next: string[]) => { + latestRef.current = next + setDraft(next) + } + + const commit = useCallback(() => { + if (doneRef.current) return + doneRef.current = true + if (cancelledRef.current) { + onCancel() + return + } + const ids = latestRef.current + onSave(isMulti ? ids : (ids[0] ?? null), 'enter') + }, [isMulti, onSave, onCancel]) + + // Escape closes the Radix menu (firing `onOpenChange(false)`); capture it + // first so the close handler discards instead of committing. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') cancelledRef.current = true + } + document.addEventListener('keydown', onKeyDown, true) + return () => document.removeEventListener('keydown', onKeyDown, true) + }, []) + + const handleOpenChange = (next: boolean) => { + setOpen(next) + if (!next) commit() + } + + const handleSelectOption = (event: Event, id: string) => { + if (!isMulti) { + // Picking closes the menu → `handleOpenChange` commits the new value. + setDraftAnd([id]) + return + } + // Keep the menu open across toggles; commit the set on close. + event.preventDefault() + const has = latestRef.current.includes(id) + const next = has ? latestRef.current.filter((v) => v !== id) : [...latestRef.current, id] + // A required multiselect can't be emptied — ignore removing the last option. + if (column.required && next.length === 0) return + setDraftAnd(next) + } + + return ( + + +