Skip to content

Commit 8329dac

Browse files
feat(tables): add select & multi-select column types (#5873)
* feat(tables): add select/multiselect column types (backend) Adds two enum-style column types where the column declares a fixed set of options (stable id + name + palette color) and every cell is constrained to them. - COLUMN_TYPES gains `select` / `multiselect`; SELECT_COLORS is a fixed, theme-aware palette mapping 1:1 to Badge color variants (no raw hex) - ColumnDefinition.options carries the option set. Cells store option *ids* (a string for select, string[] for multiselect) so renaming or recoloring an option never rewrites row data - Row validation enforces membership; coercion tolerantly maps an option *name* to its id for tool/import writes and drops unmatched entries - validateColumnDefinition enforces non-empty, unique-id, unique-name and valid-color option sets, and rejects options on non-select columns - Contract gains selectOptionSchema plus a cross-field refine requiring options exactly on select types; routes thread options through type changes and a new options-only updateColumnOptions path No migration: column config already lives in the user_table_definitions schema JSONB blob. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e4MvFV1szLhNLap1CCNUd * feat(tables): select/multiselect column UI Surfaces the new enum column types in the tables grid. - New `select-field/` module: SelectPill (colored option via the shared Badge palette), SelectValueEditor (one ChipDropdown-backed picker reused by every edit surface), SelectOptionsEditor (add/rename/recolor/remove) - Option colors are picked from inline squircle swatches — no labels, no nested dropdown. Each swatch's fill is a Badge in that variant, so the palette stays single-sourced and theme-aware - Cells render option pills; an empty select cell shows a muted "None" so it reads as a dropdown. The single-select menu always offers "None" to clear - Wired into all three edit surfaces (inline cell, expanded popover, row modal) plus the type picker and column-type icons - ChipDropdown gains `defaultOpen`/`onOpenChange` so the inline cell editor can open on mount and commit when the menu closes; open state is now controlled in both single and multi modes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e4MvFV1szLhNLap1CCNUd * fix(tables): auto-fit select columns on option labels, not ids Column auto-resize measured `String(val)` for every non-json/date column, which for a select cell is the opaque option id (and for multiselect the comma-joined id array). Selecting auto-fit on a select column therefore sized it to ids the user never sees. Measure the resolved option names instead, matching what the pills actually render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e4MvFV1szLhNLap1CCNUd * feat(tables): default select options to grey, drop color picker for now Removes the per-option swatch picker and defaults every option to the neutral gray pill. The `color` field stays in the data model and the SELECT_COLORS palette/contract are untouched, so a picker can be re-added later as a pure UI change with no migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e4MvFV1szLhNLap1CCNUd * fix(tables): guard select type conversion, escape, and required clear Addresses review findings on the select/multiselect column types: - Column type conversion now checks each existing value against the target option set (resolve by id or name); a `select`/`multiselect` change is blocked when values don't fit, instead of accepting shapes that later coercion would strand or silently drop - Inline select editor discards its draft on Escape (matching the text/date editors) rather than committing on menu close - A required single-select no longer offers "None" — clearing to null could never be committed Exports resolveSelectOptionId from validation for the conversion gate. * fix(tables): block emptying required multiselect, skip no-op cell writes Round 2 review fixes: - A required multiselect can no longer be emptied — the toggle that would remove the last option is ignored, since an empty selection can't be committed (server rejects it). Mirrors the required single-select "None" guard. - Inline cell save now compares old vs new value structurally, so a no-op edit (e.g. opening a multiselect and closing it unchanged, producing a new array reference) no longer writes a row update or pushes an undo entry. Uses JSON compare for arrays/objects, matching the existing optionsEqual convention; primitives keep the === fast path. * fix(tables): expanded multiselect string value + same-type options drop Round 3 review fixes: - Expanded-cell popover normalizes a multiselect's initial value with toSelectedIds, so a single option-id string (the normal shape right after a select->multiselect conversion, before the row is rewritten) is no longer collapsed to an empty array and cleared on save. - Column PATCH now only routes to updateColumnType on a real type change. updateColumnType early-returns on an unchanged type, so a payload repeating the current type together with new options previously applied neither; an unchanged type with options now routes to the options-only update. Fixed in both the internal and v1 column routes. * improvement(tables): inline select edit + idiomatic options editor - Double-clicking a select/multiselect cell now opens the inline option dropdown (like date/number) instead of the fixed-height text popover, which rendered just a small dropdown floating in a large empty container. Removes the now-unreachable ExpandedSelectEditor from the expanded-cell popover. - Options editor's add/remove controls match the sibling Filter UI: a ghost, muted "Add option" button with a small plus, and an X remove icon. * improvement(tables): inline select cell uses bare DropdownMenu The inline select editor used a ChipDropdown pill, which reads as a foreign form control inside a grid cell. It now renders the canonical DropdownMenu anchored to the cell — an invisible full-cell trigger (no pill, no label text), options as their colored pills with a check on the selected ones, and "None" as a proper menu item. The cell keeps showing its pills while the menu is open instead of going blank. SelectValueEditor (the ChipDropdown pill) is now used only by the row modal, where a pill form control is idiomatic. This lets the defaultOpen/onOpenChange additions to the shared ChipDropdown be reverted — it's back to its original behavior with no consumers depending on the change. * improvement(tables): unify select into one type with a multiple flag - Collapse `select` + `multiselect` into a single `select` column type with a `multiple` boolean. A cell holds one option id when single, an array when multiple. Validation/coercion, the contract, routes, and all cell/editor code now branch on `column.multiple` instead of a separate type. - The column-config sidebar exposes an "Allow multiple" toggle (create and edit) and no longer offers "Unique" on select columns. - Give the closed select cell a dropdown affordance: it renders emcn's `comboboxVariants` chrome (border + chevron, compacted to the row height), with the pills inside and the chevron flipping while the menu is open. - Single↔multiple toggles reconcile lazily on the next row write (single tolerates an array by taking its first resolved option). * improvement(tables): revert select cell to chip-only view Drop the combobox chrome (border + chevron) from the select cell — the plain option pills read better. Reverts the comboboxVariants barrel export too, since nothing else uses it. * fix(tables): block multiple→single select switch when cells have >1 option Switching a select column from multiple to single would silently keep only the first option of any multi-valued cell. updateColumnOptions now scans the rows on that transition and errors ("N row(s) have multiple options selected") instead of dropping data, mirroring the type-change compatibility guard. * improvement(tables): rename select "Allow multiple" toggle to "Multiselect" * fix(tables): drop removed select options from cells instead of stale pills When an option is deleted, cells that referenced it previously rendered a gray fallback pill labeled with the raw internal id. They now drop the orphaned id and fall back to empty ("None"). Editors seed their selection from the still- valid ids too, so editing/saving such a cell writes the cleaned value. * improvement(tables): use dashed add-row button for select options Match the sub-block list editors (filter/sort builders): a full-width, dashed-border ghost "Add option" button instead of a plain text button. * perf(tables): don't refetch rows on metadata-only column saves; fix copy log - Add/update column are metadata-only server-side (row data never changes), so they now invalidate just the schema, not the rows. Saving a column (e.g. editing select options) no longer triggers a full rows refetch/flash; cells re-render from the refetched schema. Delete-column and workflow-group ops keep the rows invalidation since they do change row data. - "Failed to copy rows" logged an empty {} for DOMExceptions; log the extracted message so the real reason (e.g. lost transient activation) is visible. * fix(tables): migrate legacy multiselect columns; readable contract error - Tables created before the select/multiselect merge stored `type: 'multiselect'`, which the removed type no longer accepts — every response carrying such a column failed contract validation and column edits threw. getTableById now maps `multiselect` → `select` + `multiple: true` on read (covers reads, column ops via withLockedTable, and their responses); the migrated shape persists on the table's next schema write. - requestJson's "Response failed contract validation" now appends a short "field: reason" summary of the Zod issues instead of a bare message, so the failing field is visible. * revert(tables): drop legacy multiselect read-migration Only local dev tables ever held the removed `multiselect` type (the feature never shipped), so the read-time backward-compat mapping isn't warranted. Keeps the readable contract-validation error from the same change. * improvement(tables): add select options by typing into a trailing row Replace the "Add option" button with a trailing empty row: the first keystroke materializes the option and focus jumps into it (cursor at end) so typing flows straight through. Enter in an option jumps back to the trailing row to add the next one. Removing a row is unchanged. * fix(tables): export select columns as option names, not ids CSV/JSON export serialized the raw stored value for select cells — an option id (or an array of ids for multi) — so downloads showed opaque `opt_…` ids. Export now resolves ids to option names: CSV comma-joins multi names; JSON resolves values before the id→name key translation. Ids with no matching option (deleted) are dropped. * fix(tables): resolve select option ids to names across all read/consume surfaces Stored select cells hold opaque option ids; only the cell renderer and write coercion handled id<->name. Every other read/serialize/search boundary leaked the id. Centralize translation in lib/table/select-values and apply it at each boundary: - reads return names: sync export (CSV+JSON), function/snapshot mounts, clipboard copy/cut, workflow-tool + mothership reads, v1 API rows - filter/search: tolerant name->id on filter operands (row-wire INTERNAL_JWT, mothership, v1), select-aware SQL predicates (operator whitelist, multiselect empty), grid Cmd-F matches resolved names, UI filter option picker sends ids - sort: select columns order alphabetically by option name (id->name CASE) - multiselect writes split a comma-delimited string; mothership add/update_column forward options/multiple * refactor(tables): remove vestigial per-option color from select columns Colors were never exposed (no picker; every option hardcoded to gray), only kept in the model for a future picker. Drop the field entirely: SelectOption is now { id, name }, pills render a fixed neutral badge, and validation/contract no longer carry a color. Existing stored options keep a harmless color key that is stripped on the next read/write. * feat(tables): generate select option ids in the copilot handler from agent-supplied names The mothership agent authors select options by name only; the copilot tool handler now mints the stable option id (preserving any id it's re-sent, so existing cell data survives an options edit) before calling the table service — the model never authors the cell key. Applied to create (per select column in the schema), add_column, and update_column. * refactor(tables): collapse row read boundaries onto one outbound seam Turning a stored row into its outward form needs two translations — keys (column id to name) and values (select option id to name) — and every boundary composed them by hand. That shape leaked twice: table-change triggers and workflow-column/enrichment inputs both built name-keyed rows without resolving select values, so workflows saw opt_a1b2 instead of "Open". Neither had any test coverage; neither is live (select is unreleased on staging). lib/table/cell-format.ts now fuses both into a single pass: - namedRowMapper(columns) — keys + values together, so they cannot drift - fillMissingColumns(named, columns) — widens to match a headers array - mapInputValues(data, columns, mappings) — for id-keyed input mappings Migrated the 12 hand-composed pairings and the inlined copy in export-runner, then deleted rowDataIdToName, resolveRowSelectValues and selectColumnsOf so the keys-without-values shape is unrepresentable. Also formats dates in the read-only expanded popover, which rendered raw storage to viewer-role users. CSV is untouched (export-format.ts has no diff), so export bytes are unchanged. * fix(tables): stream the persisted cell value, not the raw workflow output A workflow column writing into a select column persists correctly — updateRow coerces the option name to its stored id — but it coerces its own merged copy, so the live SSE snapshot still carried the raw value. The grid resolved that name as an option id, found nothing, and rendered the cell EMPTY from the moment the workflow completed until the next refetch: the write looked like it had failed at exactly the moment the user was watching it land. Coerce a copy of the event outputs through the same inbound switch the persist path uses. A copy because the patch object is identity-compared for the progress writer's retry bookkeeping. This aligns every type, not just select — a date now streams canonically, and a value the DB would reject streams as null instead of briefly showing a value that was never stored. * fix(tables): keep option ids stable when the agent edits a select column The agent authors select options as bare names, so normalizeSelectOptionsInput minted a fresh id for every one. On update_column that replaced the whole option list with new ids — orphaning every cell that referenced the old ones and silently clearing the column's data on what looks like an additive edit (adding "Medium" to ["Low", "High"] wiped both existing values). Match incoming names against the column's current options (case-insensitively) and reuse those ids; only genuinely new options get a fresh one. Found while writing the catalog description that promised this behavior. Also regenerates the tool catalog now that the copilot registry advertises select columns, options and multiple. * fix(tables): filter multi-select columns by membership, not equality A multi-select cell stores an array of option ids, and Postgres containment does not match a scalar against an array — `{"t":["a"]} @> {"t":"a"}` is false. So every multi-select filter compiled to a predicate that could never be true and silently returned nothing. Split the select operator whitelist by cardinality: single-select keeps eq/ne/in/nin, multi-select takes contains/ncontains, both keep isEmpty. The membership clause wraps the operand (`data @> '{"t":["a"]}'`), which still uses the same GIN index. The equality shorthand (`{ tags: 'opt_a' }`) compiles to membership too — it bypasses the operator whitelist, so it had to be handled or it would keep failing silently. Also resolves $contains/$ncontains operands name → id, so an agent or API caller filtering by option name reaches the right rows, and narrows the filter UI's operator list per cardinality. * chore(tables): drop a stale doc comment and trim two restating ones The expanded-cell-popover TSDoc claimed workflow and boolean cells are read-only there, but the editability rule has no workflow check and the inline comment beside it says the opposite — a comment that contradicts the code is worse than none. The accurate rule already sits next to what enforces it. * fix(tables): close review findings on select column conversion and editing Bugbot round on the select work — eight findings, all real: - Converting away from select left opaque option ids in every cell. Migrate ids to option names in the same transaction (multi joins comma-separated), and run the compatibility check against the name, not the id. - Multiselect to text silently dropped arrays; text targets now reject structured values instead of nulling them on the next write. - Converting to single-select accepted multi-valued cells, which the next coerce would quietly truncate to the first id. Same guard updateColumnOptions already had. - Empty strings had become compatible with every target type, so a text column with blank cells could convert to number. Narrow that back to select. - Copilot update_column rejected a multiple-only payload with 'options is required', contradicting the catalog. Fall back to the column's options. - Empty multiselect rendered ChipDropdown's 'All' label, reading as if every option were selected. - Opening and dismissing an empty multiselect wrote a row update, since null and [] compared unequal. - Converting a unique column to select stranded the constraint with no way to clear it. Adds the first tests for the conversion rules. * fix(tables): migrate select cells in both directions and refetch rows after Follow-up round on the conversion fix — the migration was one-directional. - Converting TO select left cells holding option names, which every reader resolves by id, so populated cells rendered as None and matched no filter. - Toggling single→multi left scalar ids while multi filters compile to array containment, which a scalar never matches — pre-toggle rows silently dropped out of their own column's filters. - useUpdateColumn settled with the schema-only invalidation, whose premise ('stored row data never changes') these migrations broke, so the grid kept serving pre-migration cells. Both directions now share set-based helpers keyed off a jsonb map, and the to-select map keys ids as well as names so re-running is a no-op. The client refetches rows when the payload carries a type or multiple change. * fix(tables): align select cell migration with the compatibility check Two spots where the check and the migration disagreed, each leaving a cell the check had already accounted for: - resolveSelectOptionId matches option names case-insensitively, so a cell like 'open' against option 'Open' passed the convert-to-select check, but the migration map was case-sensitive and left it as the raw name. The map now keys the folded name too (duplicate names are already rejected case-insensitively, so it is unambiguous) and lookups try the exact form first. - Converting away from select, the check treats an orphaned option id as null while the migration passed it through, so a column with deleted-option cells could land an opaque opt_ id in a number/date/boolean cell. Orphans now null, matching selectValueForConversion; a multi cell drops them and nulls when nothing survives. This reverses the pass-through choice from the earlier round — consistency with the check is what matters, and an orphan is a value the UI never rendered. * fix(tables): treat an emptied multiselect as empty everywhere `[]` is the canonical empty multiselect but it is not falsy, so every emptiness check that only looked for null/undefined/'' read a cleared selection as filled: - dependent workflow groups became eligible off an empty cell, and enrichments with a required input ran on rows they should have skipped - marking a column required did not count `[]` rows, even though write-path validation rejects an empty required multiselect One `isEmptyCellValue` predicate now backs the dep, output-filled and enrichment checks, and the required-constraint guard counts '[]' too. Also normalizes '' on conversion to select — compatibility admits it, so it has to land as null (single) or [] (multi) rather than a bare string the column's own validation would then reject. * fix(tables): route an unchanged type with options to the options update updateColumnType early-returns when the type is unchanged, so an agent that restated newType: 'select' alongside a new option set had those options silently dropped while the tool still reported success. The HTTP columns route already guards this; the copilot path now mirrors it — only a genuine type change goes to updateColumnType, anything else with options or multiple goes to updateColumnOptions. A payload that only restates the current type is a no-op and now reports the live schema rather than an undefined one. * fix(tables): restore select options on column-delete undo; map v1 option errors to 400 - The delete-column undo snapshot never captured options or multiple, so re-creating a select column was rejected outright (it is invalid with no option set) and the saved cell data — which is option ids — had nothing to attach to. Undo of a select column deletion simply could not succeed. - The v1 add-column route mapped only 'already exists' and 'maximum column' to 400, while the internal route also maps invalid-column and option errors. A bad select option set surfaced as a 500 on the public API. * fix(tables): clear unique in the service when converting a column to select The clearing lived only in the column-config sidebar, so a conversion through the v1 API or the copilot tool left unique: true stranded on a select column — where the constraint compares the stored option id, capping each option at one row for the whole table, and the sidebar hides the toggle so it could never be cleared again. Moving it into updateColumnType gives all three callers the same behavior; the sidebar's payload is now belt-and-braces rather than the only enforcement. * fix(tables): clear removed select options, reject unique selects, resolve pasted names - updateColumnOptions now drops ids for options that no longer exist, so a removed option can't leave orphans that block edits on required columns - validateColumnDefinition and updateColumnConstraints reject unique on select - cleanCellValue resolves pasted option names to ids client-side so the optimistic cache holds ids and the pill renders immediately * fix(tables): refetch rows when a select option is removed Removing an option rewrites cells server-side, so the schema-only invalidation left the cache holding orphaned ids — hidden by the grid but still visible to emptiness checks, filters, and dependent-group eligibility. * fix(tables): let a multiselect round-trip through text Converting multiselect to text flattens cells to `Alpha, Beta`, but converting back read that as one unknown option and rejected the change. Both the compatibility check and the cell migration now split a comma string the way the write path already did, through one shared helper. * fix(tables): scope the empty-selection guard to multiselect; order the option ref map - cellValuesEqual treated null and [] as equal for every type, so clearing a stored [] on a json cell (or writing one into a null cell) never saved - migrateCellsToSelectIds built one flat id/name map, letting an option whose name equals another option's id overwrite that id and repoint its cells * fix(tables): prune stale select filters; reject unique+select before converting An applied filter outlives the schema it was built against, so converting a column to select (or toggling multiple) could strand an operator the server rejects, failing every subsequent rows query until the filter was cleared by hand. Prune those conditions in useTable, above every consumer of the rows query key, and share one operator whitelist with the filter picker. A payload setting both newType: "select" and unique: true ran two separate locked transactions, committing the conversion and then failing the constraint. Both routes now reject that pair before either runs. * fix(tables): scope bulk ops to the pruned filter; guard unique+select in copilot useTable pruned the filter for the rows query but kept it private, so select-all run/stop/delete still sent the raw one — targeting a predicate the grid wasn't displaying and that the server rejects. Return it and use it for every server-bound scope; the filter button and editor keep showing what the user configured so the stale rule can still be repaired. The copilot update_column path ran the same two-transaction sequence the HTTP routes now reject, so a convert-to-select plus unique payload committed the conversion and then failed. Same guard applied. Also adds the filter to selectedRunScope's deps — it was read but not listed, so a filter change while select-all was active carried a stale scope. * fix(tables): reject the multiple flag on non-select columns A create/add payload could persist multiple: true on a string column, where it sits inert until updateColumnType inherits it via `data.multiple ?? column.multiple` — silently turning an intended single-select into a multiselect and rewriting every cell as an array. Rejected now in both the service validator and the shared contract refine, which the internal and v1 column routes both consume. * fix(tables): block required blank conversions; sort multiselect by option name `required` only rejects null/undefined on a write, so a required string column legitimately holds ''. Converting it to a required select stored null (or [] for a multi), and every later update of that row then failed its own required check. Reject a blank source value when the target select is required. Multiselect sort compared the raw id-array text, since `["opt_b","opt_a"]` matches no single-id CASE branch. It now resolves elements to names and joins them in stored order — the same text the grid renders and an export writes. * fix(tables): validate a conversion against the required flag the request sets A single PATCH converting an optional column to select while also setting required: true checked blanks against the column's CURRENT required flag, so the conversion committed and the constraint write then failed — an error response with the type change already persisted. updateColumnType now takes the requested `required` and validates against the constraint the column ends up with. Empty rows are also counted when the target is required (they were skipped outright), so the null case fails up-front too rather than at the constraint write. * fix(tables): one emptiness predicate, guard required option removal, order aggregates The conversion pre-flight and the constraint write each had their own idea of "empty", and rows missing the column key are filtered out of the conversion's row set entirely — so a combined type + required PATCH passed the pre-flight, committed the conversion, then failed the constraint. Both now call one countEmptyCells helper, which is the actual fix: they can no longer disagree. Removing the last option a required cell holds nulled it, producing exactly the state updateColumnConstraints refuses to create. Blocked with a count of the rows that would be stranded. The multiselect rewrite aggregates carried no ORDER BY while sort and export preserve stored order via WITH ORDINALITY; they now do too. * fix(tables): restore in/nin on select filters and assert the whitelists agree The client operator set was a hand transcription of the server's and dropped $in/$nin, so the picker never offered them and — worse — pruneFilterForColumns silently discarded an existing $in filter the server would have accepted. Both server sets are now exported and a test maps the UI operators onto them through UI_TO_WIRE_OPERATOR, so the two can't drift again by hand. * fix(tables): stop Find crashing on a null multiselect cell jsonb_array_elements_text throws "cannot extract elements from a scalar" on a JSON null, which is exactly what a multiselect cell holds once it is cleared, cut, or has its last option removed — so search failed for the whole table. Gate the array arm on jsonb_typeof, falling back to the single mapping so a scalar left over from a single->multi toggle stays searchable. Swept the other array expansions: the rest are inside UPDATEs whose WHERE already filters to arrays, or are CASE-guarded. This was the only unguarded one. * fix(tables): clear removed options before the cardinality guard and migration The multi->single guard counted options the same request was dropping, so trimming a cell to one option and turning multiselect off in one save was rejected. Reordering fixes that and a latent corruption: the migration keeps a multi cell's FIRST element, which could be a removed id sitting ahead of a kept one, so the surviving option would be discarded and the dead one kept. Removal now runs first, against the pre-toggle cell shape, then the guard counts what actually remains, then the shape migration runs. Find also joined multiselect names unordered while sort and export preserve stored order, so a search for the displayed label could miss. * fix(tables): validate option removal against the pending required flag; show the applied filter The stranded-options guard read the column's current `required`, so a removal paired with `required: true` cleared cells and then failed the constraint write — options change committed behind an error — while a removal paired with `required: false` was blocked despite the column no longer being required. It now takes the requested value, and also pre-flights already-empty rows when required is newly imposed. The filter chip and editor keyed off the raw filter while rows, runs, stops and deletes used the pruned one, so the UI could claim an active filter the grid wasn't reflecting. Both now read the applied filter. * fix(tables): gate the unique guard on the resulting type, not on a type change Pairing unique: true with an options or multiple update on a column that is ALREADY select skipped the guard, so updateColumnOptions committed and the separate constraint write then failed — the options change landing behind a 400. Gating on `updates.type ?? currentColumn.type` covers the conversion and the options-only case with one condition, on both column routes and the copilot update_column path. * fix(tables): stop coercing select option ids in filter values Option ids are caller-supplied strings, so parseScalar turned an id of "1" or "true" into a number or boolean; JSONB containment then compared the wrong type and matched nothing while the picker still showed a valid option. filterRulesToFilter takes the columns and keeps a select value as text, for $in lists too. pruneFilterForColumns forwards them as well — its round-trip through rules would otherwise re-coerce the ids it just preserved. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 892750b commit 8329dac

69 files changed

Lines changed: 4149 additions & 333 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import {
1515
deleteColumn,
1616
renameColumn,
1717
updateColumnConstraints,
18+
updateColumnOptions,
1819
updateColumnType,
1920
} from '@/lib/table'
21+
import { columnMatchesRef } from '@/lib/table/column-keys'
2022
import { accessError, checkAccess, normalizeColumn, rootErrorMessage } from '@/app/api/table/utils'
2123

2224
const logger = createLogger('TableColumnsAPI')
@@ -68,7 +70,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
6870
msg.includes('already exists') ||
6971
msg.includes('maximum column') ||
7072
msg.includes('Invalid column') ||
71-
msg.includes('exceeds maximum')
73+
msg.includes('exceeds maximum') ||
74+
msg.includes('option')
7275
) {
7376
return NextResponse.json({ error: msg }, { status: 400 })
7477
}
@@ -116,9 +119,50 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
116119
)
117120
}
118121

119-
if (updates.type) {
122+
// A payload that repeats the current type must not go through
123+
// `updateColumnType` — it early-returns on an unchanged type and would drop
124+
// any `options` alongside it. Only a real type change routes there; an
125+
// unchanged type with options routes to the options-only update.
126+
const currentColumn = table.schema.columns.find((c) =>
127+
columnMatchesRef(c, validated.columnName)
128+
)
129+
const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type
130+
131+
// Every write below is its own locked transaction, so any of them paired
132+
// with a constraint write that is going to fail commits and then errors.
133+
// Gate on the type the column ENDS UP with, not on whether the type is
134+
// changing: an options-only update on an existing select column carries the
135+
// same hazard as a conversion does.
136+
const resultingType = updates.type ?? currentColumn?.type
137+
if (updates.unique === true && resultingType === 'select') {
138+
return NextResponse.json({ error: 'Cannot set a select column as unique' }, { status: 400 })
139+
}
140+
141+
if (typeChanging) {
120142
updatedTable = await updateColumnType(
121-
{ tableId, columnName: updates.name ?? validated.columnName, newType: updates.type },
143+
{
144+
tableId,
145+
columnName: updates.name ?? validated.columnName,
146+
newType: updates.type as NonNullable<typeof updates.type>,
147+
...(updates.options !== undefined ? { options: updates.options } : {}),
148+
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
149+
// Forwarded so the conversion validates against the constraint this
150+
// same request is about to set, not the column's current one.
151+
...(updates.required !== undefined ? { required: updates.required } : {}),
152+
},
153+
requestId
154+
)
155+
} else if (updates.options !== undefined || updates.multiple !== undefined) {
156+
updatedTable = await updateColumnOptions(
157+
{
158+
tableId,
159+
columnName: updates.name ?? validated.columnName,
160+
options: updates.options ?? currentColumn?.options ?? [],
161+
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
162+
// Forwarded so the removal guard validates against the constraint this
163+
// same request is about to set, not the column's current one.
164+
...(updates.required !== undefined ? { required: updates.required } : {}),
165+
},
122166
requestId
123167
)
124168
}
@@ -162,7 +206,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
162206
msg.includes('Invalid column') ||
163207
msg.includes('exceeds maximum') ||
164208
msg.includes('incompatible') ||
165-
msg.includes('duplicate')
209+
msg.includes('duplicate') ||
210+
msg.includes('option')
166211
) {
167212
return NextResponse.json({ error: msg }, { status: 400 })
168213
}

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

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { neutralizeCsvFormula } from '@/lib/core/utils/csv'
88
import { generateRequestId } from '@/lib/core/utils/request'
99
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1010
import { captureServerEvent } from '@/lib/posthog/server'
11-
import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys'
11+
import { namedRowMapper } from '@/lib/table/cell-format'
12+
import { getColumnId } from '@/lib/table/column-keys'
13+
import { formatCsvCell } from '@/lib/table/export-format'
1214
import { queryRows } from '@/lib/table/rows/service'
1315
import { accessError, checkAccess } from '@/app/api/table/utils'
1416

@@ -52,7 +54,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
5254
const columns = table.schema.columns
5355
// Stored row data is id-keyed; CSV headers and JSON keys are display names, so
5456
// translate id → name on the way out (export is a name-friendly boundary).
55-
const nameById = buildNameById(table.schema)
57+
const toNamedRow = namedRowMapper(columns)
5658
const safeName = sanitizeFilename(table.name)
5759
const filename = `${safeName}.${format}`
5860

@@ -100,14 +102,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
100102

101103
for (const row of result.rows) {
102104
if (format === 'csv') {
103-
const values = columns.map((c) => formatCsvValue(row.data[getColumnId(c)]))
105+
const values = columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)]))
104106
controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`))
105107
} else {
106108
const prefix = firstJsonRow ? '' : ','
107109
firstJsonRow = false
108-
controller.enqueue(
109-
encoder.encode(prefix + JSON.stringify(rowDataIdToName(row.data, nameById)))
110-
)
110+
controller.enqueue(encoder.encode(prefix + JSON.stringify(toNamedRow(row.data))))
111111
}
112112
}
113113

@@ -144,18 +144,6 @@ function sanitizeFilename(name: string): string {
144144
return cleaned || 'table'
145145
}
146146

147-
/**
148-
* Serializes a cell for CSV. Only string cells are formula-neutralized; numbers,
149-
* booleans, dates, and JSON objects can never form a trigger and pass through verbatim.
150-
*/
151-
function formatCsvValue(value: unknown): string {
152-
if (value === null || value === undefined) return ''
153-
if (value instanceof Date) return value.toISOString()
154-
if (typeof value === 'object') return JSON.stringify(value)
155-
if (typeof value === 'string') return neutralizeCsvFormula(value)
156-
return String(value)
157-
}
158-
159147
function toCsvRow(values: string[]): string {
160148
return values.map(escapeCsvField).join(',')
161149
}

apps/sim/app/api/table/row-wire.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { AuthType, type AuthTypeValue } from '@/lib/auth/hybrid'
22
import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
3+
import { namedRowMapper } from '@/lib/table/cell-format'
34
import {
45
buildIdByName,
5-
buildNameById,
66
filterNamesToIds,
7-
rowDataIdToName,
87
rowDataNameToId,
98
sortNamesToIds,
10-
} from '@/lib/table'
9+
} from '@/lib/table/column-keys'
10+
import { resolveFilterSelectValues } from '@/lib/table/select-values'
1111

1212
export interface RowWireTranslators {
1313
/** Inbound row data: wire keys → storage column ids. */
@@ -36,11 +36,12 @@ export function rowWireTranslators(
3636
return { dataIn: identity, dataOut: identity, filterIn: identity, sortIn: identity }
3737
}
3838
const idByName = buildIdByName(schema)
39-
const nameById = buildNameById(schema)
4039
return {
40+
dataOut: namedRowMapper(schema.columns),
4141
dataIn: (data) => rowDataNameToId(data, idByName),
42-
dataOut: (data) => rowDataIdToName(data, nameById),
43-
filterIn: (filter) => filterNamesToIds(filter, idByName),
42+
// Rekey field refs name → id, then resolve select operand names → ids.
43+
filterIn: (filter) =>
44+
resolveFilterSelectValues(filterNamesToIds(filter, idByName), schema.columns),
4445
sortIn: (sort) => sortNamesToIds(sort, idByName),
4546
}
4647
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,5 +279,7 @@ export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
279279
required: col.required ?? false,
280280
unique: col.unique ?? false,
281281
...(col.workflowGroupId ? { workflowGroupId: col.workflowGroupId } : {}),
282+
...(col.options ? { options: col.options } : {}),
283+
...(col.multiple ? { multiple: true } : {}),
282284
}
283285
}

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

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import {
1414
deleteColumn,
1515
renameColumn,
1616
updateColumnConstraints,
17+
updateColumnOptions,
1718
updateColumnType,
1819
} from '@/lib/table'
20+
import { columnMatchesRef } from '@/lib/table/column-keys'
1921
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
2022
import {
2123
checkRateLimit,
@@ -86,7 +88,15 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
8688
if (validationResponse) return validationResponse
8789

8890
if (error instanceof Error) {
89-
if (error.message.includes('already exists') || error.message.includes('maximum column')) {
91+
// Same caller-error set the internal columns route maps — an invalid
92+
// select option set is a bad request, not a server fault.
93+
if (
94+
error.message.includes('already exists') ||
95+
error.message.includes('maximum column') ||
96+
error.message.includes('Invalid column') ||
97+
error.message.includes('exceeds maximum') ||
98+
error.message.includes('option')
99+
) {
90100
return NextResponse.json({ error: error.message }, { status: 400 })
91101
}
92102
if (error.message === 'Table not found') {
@@ -138,9 +148,50 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
138148
)
139149
}
140150

141-
if (updates.type) {
151+
// A payload that repeats the current type must not go through
152+
// `updateColumnType` — it early-returns on an unchanged type and would drop
153+
// any `options` alongside it. Only a real type change routes there; an
154+
// unchanged type with options routes to the options-only update.
155+
const currentColumn = table.schema.columns.find((c) =>
156+
columnMatchesRef(c, validated.columnName)
157+
)
158+
const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type
159+
160+
// Every write below is its own locked transaction, so any of them paired
161+
// with a constraint write that is going to fail commits and then errors.
162+
// Gate on the type the column ENDS UP with, not on whether the type is
163+
// changing: an options-only update on an existing select column carries the
164+
// same hazard as a conversion does.
165+
const resultingType = updates.type ?? currentColumn?.type
166+
if (updates.unique === true && resultingType === 'select') {
167+
return NextResponse.json({ error: 'Cannot set a select column as unique' }, { status: 400 })
168+
}
169+
170+
if (typeChanging) {
142171
updatedTable = await updateColumnType(
143-
{ tableId, columnName: updates.name ?? validated.columnName, newType: updates.type },
172+
{
173+
tableId,
174+
columnName: updates.name ?? validated.columnName,
175+
newType: updates.type as NonNullable<typeof updates.type>,
176+
...(updates.options !== undefined ? { options: updates.options } : {}),
177+
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
178+
// Forwarded so the conversion validates against the constraint this
179+
// same request is about to set, not the column's current one.
180+
...(updates.required !== undefined ? { required: updates.required } : {}),
181+
},
182+
requestId
183+
)
184+
} else if (updates.options !== undefined || updates.multiple !== undefined) {
185+
updatedTable = await updateColumnOptions(
186+
{
187+
tableId,
188+
columnName: updates.name ?? validated.columnName,
189+
options: updates.options ?? currentColumn?.options ?? [],
190+
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
191+
// Forwarded so the removal guard validates against the constraint this
192+
// same request is about to set, not the column's current one.
193+
...(updates.required !== undefined ? { required: updates.required } : {}),
194+
},
144195
requestId
145196
)
146197
}
@@ -195,7 +246,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
195246
msg.includes('Invalid column') ||
196247
msg.includes('exceeds maximum') ||
197248
msg.includes('incompatible') ||
198-
msg.includes('duplicate')
249+
msg.includes('duplicate') ||
250+
msg.includes('option')
199251
) {
200252
return NextResponse.json({ error: msg }, { status: 400 })
201253
}

apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,9 @@ import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server
1313
import { generateRequestId } from '@/lib/core/utils/request'
1414
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1515
import type { RowData, TableSchema } from '@/lib/table'
16-
import {
17-
buildIdByName,
18-
buildNameById,
19-
rowDataIdToName,
20-
rowDataNameToId,
21-
updateRow,
22-
} from '@/lib/table'
16+
import { updateRow } from '@/lib/table'
17+
import { namedRowMapper } from '@/lib/table/cell-format'
18+
import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys'
2319
import { accessError, checkAccess } from '@/app/api/table/utils'
2420
import {
2521
checkRateLimit,
@@ -88,13 +84,13 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou
8884
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
8985
}
9086

91-
const nameById = buildNameById(result.table.schema as TableSchema)
87+
const toNamedRow = namedRowMapper((result.table.schema as TableSchema).columns)
9288
return NextResponse.json({
9389
success: true,
9490
data: {
9591
row: {
9692
id: row.id,
97-
data: rowDataIdToName(row.data as RowData, nameById),
93+
data: toNamedRow(row.data as RowData),
9894
position: row.position,
9995
createdAt:
10096
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
@@ -142,7 +138,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
142138
}
143139

144140
const idByName = buildIdByName(table.schema as TableSchema)
145-
const nameById = buildNameById(table.schema as TableSchema)
141+
const toNamedRow = namedRowMapper((table.schema as TableSchema).columns)
146142
const updatedRow = await updateRow(
147143
{
148144
tableId,
@@ -168,7 +164,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
168164
data: {
169165
row: {
170166
id: updatedRow.id,
171-
data: rowDataIdToName(updatedRow.data, nameById),
167+
data: toNamedRow(updatedRow.data),
172168
position: updatedRow.position,
173169
createdAt:
174170
updatedRow.createdAt instanceof Date

0 commit comments

Comments
 (0)