feat(tables): add select & multi-select column types#5873
feat(tables): add select & multi-select column types#5873TheodoreSpeaks wants to merge 12 commits into
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 API & schema: Zod contracts and UI: Select appears in the new-column and edit sidebars ( Reviewed by Cursor Bugbot for commit 60b15cc. 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.
Important Files Changed
Reviews (5): Last reviewed commit: "improvement(tables): inline select edit ..." | 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 |
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 a0f898a. Configure here.
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.
| targetOptions: SelectOption[] = [] | ||
| ): boolean { | ||
| if (value === null || value === undefined) return true | ||
| if (value === null || value === undefined || value === '') return true |
There was a problem hiding this comment.
Empty strings bypass type checks
Medium Severity
isValueCompatibleWithType now treats '' as compatible for every target type, not only select. A string column with empty-string cells can convert to number/date/boolean, leaving invalid values that later block or null out on row writes.
Reviewed by Cursor Bugbot for commit 0646664. Configure here.
| * Updates the option set of a `select`/`multiselect` column without changing its | ||
| * type. Existing cell values are left untouched — ids that no longer match an | ||
| * option render as a neutral fallback pill until reassigned. | ||
| */ |
There was a problem hiding this comment.
Deleted options break row writes
High Severity
updateColumnOptions keeps orphaned cell ids after an option is removed, but every row write runs coerceRowToSchema on the full merged row. Optional orphans are nulled, multiselect orphans are stripped, and required orphans fail validation—so editing unrelated cells can erase select data or block the row.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 0646664. Configure here.
| const id = resolveSelectOptionId(entry, column.options ?? []) | ||
| if (id !== null && !ids.includes(id)) ids.push(id) | ||
| } | ||
| return { ok: true, value: ids } |
There was a problem hiding this comment.
Invalid multiselect coerces to empty
Medium Severity
Multiselect coercion always returns { ok: true, value: ids }, so a fully unmatched optional value becomes [] instead of null. That conflicts with select behavior and the stated rule that invalid optional cells coerce to null, and it persists empty arrays on import/tool writes.
Reviewed by Cursor Bugbot for commit 0646664. Configure here.
- 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).
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.
| fullWidth={fullWidth} | ||
| matchTriggerWidth={false} | ||
| /> | ||
| ) |
There was a problem hiding this comment.
Empty multiselect shows All
Medium Severity
SelectValueEditor sets showAllOption={false} and placeholder='Select options' for multiselect, but ChipDropdown in multiple mode ignores placeholder and renders allLabel (defaulting to All) when the selection is empty. Empty optional multiselect cells in the row modal therefore read as if every option were selected.
Reviewed by Cursor Bugbot for commit d55f94b. Configure here.
…ption
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
There are 6 total unresolved issues (including 4 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 60b15cc. Configure here.
| // Single or multi, every part of the value must resolve to an option. | ||
| const parts = Array.isArray(value) ? value : [value] | ||
| return parts.every((v) => resolveSelectOptionId(v as JsonValue, targetOptions) !== null) | ||
| } |
There was a problem hiding this comment.
Single-select conversion allows multi-values
Medium Severity
isValueCompatibleWithType for select only checks that every array element resolves to an option. It never consults whether the target is single-select, so a multi-element array is treated as compatible. updateColumnOptions already blocks multi→single for this reason and even comments that it mirrors a type-change guard, but that guard is missing here—so conversion can leave arrays that the next coerce silently reduces to the first id.
Reviewed by Cursor Bugbot for commit 60b15cc. Configure here.
| switch (targetType) { | ||
| case 'string': | ||
| return true | ||
| case 'select': { |
There was a problem hiding this comment.
Multiselect-to-text drops array data
Medium Severity
Compatibility for target type string returns true for any value, including multiselect string[] cells. Changing a multiselect column to text therefore succeeds while leaving arrays in row data; the next coerce cannot turn those arrays into strings and nulls optional cells (or fails required ones), so selections are lost without the conversion error used for number/date/boolean.
Reviewed by Cursor Bugbot for commit 60b15cc. 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