feat(tables): add select & multi-select column types#5873
Conversation
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
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
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
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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryMedium Risk Overview UI: column sidebar option editor, row modal and inline grid editors, pill rendering, select-aware filters, clipboard/auto-fit, and undo that restores options on column delete. Wire format: shared Guards: reject combining type change to select with Reviewed by Cursor Bugbot for commit 46fc837. Bugbot is set up for automated code reviews on this repo. Configure here. |
Greptile SummaryThis PR adds select and multiselect columns to Tables. The main changes are:
Confidence Score: 5/5This looks safe to merge.
|
| Filename | Overview |
|---|---|
| apps/sim/lib/table/columns/service.ts | Adds select conversion and option-update logic, including shared checks for empty cells and target constraints. |
| apps/sim/app/api/table/[tableId]/columns/route.ts | Forwards select options, cardinality, and requested constraints to column updates. |
| apps/sim/app/api/v1/tables/[tableId]/columns/route.ts | Adds the public API handling for select column creation and updates. |
| apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-value-editor.tsx | Provides shared single-select and multiselect editing while preventing invalid clears on required columns. |
Reviews (28): Last reviewed commit: "fix(tables): stop coercing select option..." | Re-trigger Greptile
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.
|
@cursor review |
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.
|
@cursor review |
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.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 4db5e88. Configure here.
- 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.
|
@cursor review |
|
@cursor review |
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.
|
@cursor review |
…igration 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.
|
@cursor review |
…g; 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.
|
@cursor review |
…pe 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.
|
@cursor review |
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.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 2586fe2. Configure here.
Summary
select(single) andmultiselectenum column types to Tables — a column declares a fixed set of options and every cell is constrained to them, rendered as pillscolorfield + full palette stay in the model/contract so a picker can be re-added later with no migrationChipDropdowngainsdefaultOpen/onOpenChangefor inline editingKnown limitations
options(follow-up)Type of Change
Testing
Tested manually.
bun run lintclean,check:api-validation:strictpassed,check:react-querypassed; 335 table + 14 emcn unit tests green (incl. newvalidation.test.tscovering membership, name→id coercion, and option-set validation)Checklist