Skip to content

Commit 8990821

Browse files
committed
improvement(tables): inline column creation and header-menu editing
1 parent a721a76 commit 8990821

19 files changed

Lines changed: 849 additions & 236 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx

Lines changed: 112 additions & 193 deletions
Large diffs are not rendered by default.

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,9 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [
3030
{ type: 'workflow', label: 'Workflow', icon: PlayOutline },
3131
]
3232

33-
/** Plain column types (no workflow). Used by `<ColumnConfigSidebar>`'s type combobox in edit mode. */
34-
export const PLAIN_COLUMN_TYPE_OPTIONS = COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow')
33+
/** Plain column types (no workflow). Used by the column header menu's "Change type" submenu. */
34+
export const PLAIN_COLUMN_TYPE_OPTIONS: (ColumnTypeOption & {
35+
type: ColumnDefinition['type']
36+
})[] = COLUMN_TYPE_OPTIONS.filter(
37+
(o): o is ColumnTypeOption & { type: ColumnDefinition['type'] } => o.type !== 'workflow'
38+
)

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client'
22

3+
import { useRef } from 'react'
34
import {
45
ChipChevronDown,
56
chipContentIconClass,
@@ -39,8 +40,10 @@ interface NewColumnDropdownProps {
3940

4041
/**
4142
* "+ New column" dropdown — the single entry point for creating a column.
42-
* Lists every column type plus "Workflow" and "Enrichments"; picking a type
43-
* opens the right sidebar pre-seeded.
43+
* Lists every column type plus "Workflow" and "Enrichments". Picking a scalar
44+
* type adds a draft header cell for naming — nothing persists until the name
45+
* commits (committing a select's name opens its options sidebar, and it
46+
* persists from there). Workflow and Enrichments open their own sidebars.
4447
*/
4548
export function NewColumnDropdown({
4649
trigger,
@@ -51,6 +54,8 @@ export function NewColumnDropdown({
5154
blocked,
5255
onBlocked,
5356
}: NewColumnDropdownProps) {
57+
const pendingTypeRef = useRef<ColumnDefinition['type'] | null>(null)
58+
5459
const triggerButton =
5560
trigger === 'header' ? (
5661
<button
@@ -90,7 +95,25 @@ export function NewColumnDropdown({
9095
(295px with its separator and padding), so the default cut the last
9196
two off behind a scrollbar. Sized here rather than in the shared
9297
component, which every other dropdown in the app relies on. */}
93-
<DropdownMenuContent align='start' side='bottom' sideOffset={4} className='max-h-[320px]'>
98+
{/* A type pick is deferred to here, the moment the menu has fully
99+
unmounted. Started from `onSelect`, the draft header's name input
100+
would mount while this menu is still playing its exit animation —
101+
and as the content zooms away from under the pointer, Radix's
102+
item-leave handler focuses the closing menu, stealing the input's
103+
focus mid-keystroke. The default close behavior (refocusing the
104+
trigger) is prevented for the same reason. */}
105+
<DropdownMenuContent
106+
align='start'
107+
side='bottom'
108+
sideOffset={4}
109+
className='max-h-[320px]'
110+
onCloseAutoFocus={(e) => {
111+
e.preventDefault()
112+
const type = pendingTypeRef.current
113+
pendingTypeRef.current = null
114+
if (type) onPickType(type)
115+
}}
116+
>
94117
<>
95118
<DropdownMenuItem onSelect={onPickEnrichment}>
96119
<Sparkles className='size-[14px] text-[var(--text-icon)]' />
@@ -103,7 +126,9 @@ export function NewColumnDropdown({
103126
const onSelect =
104127
option.type === 'workflow'
105128
? onPickWorkflow
106-
: () => onPickType(option.type as ColumnDefinition['type'])
129+
: () => {
130+
pendingTypeRef.current = option.type as ColumnDefinition['type']
131+
}
107132
return (
108133
<DropdownMenuItem key={option.type} onSelect={onSelect}>
109134
<Icon className='size-[14px] text-[var(--text-icon)]' />

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import React, { useCallback, useEffect, useRef, useState } from 'react'
44
import { cn } from '@sim/emcn'
55
import { ChevronDown } from '@sim/emcn/icons'
6-
import type { SortDirection, WorkflowGroup } from '@/lib/table'
6+
import type { ColumnDefinition, SortDirection, WorkflowGroup } from '@/lib/table'
7+
import { columnTypeOf } from '@/lib/table/column-types'
78
import { HeaderLabel } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/header-label'
89
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
910
import { COL_WIDTH, SELECTION_TINT_BG } from '../constants'
@@ -18,12 +19,25 @@ interface ColumnHeaderMenuProps {
1819
isRenaming: boolean
1920
isColumnSelected: boolean
2021
renameValue: string
22+
/** True after a refused rename — paints the name red until it's edited. */
23+
renameError?: boolean
2124
onRenameValueChange: (value: string) => void
2225
onRenameSubmit: () => void
2326
onRenameCancel: () => void
2427
onColumnSelect: (colIndex: number, shiftKey: boolean) => void
2528
onInsertLeft: (columnName: string) => void
2629
onInsertRight: (columnName: string) => void
30+
/** Flip the column's `unique` constraint. Only forwarded to the menu for
31+
* columns whose type supports it (registry `supportsUnique`) and that are
32+
* not workflow outputs. */
33+
onToggleUnique?: (columnName: string) => void
34+
/** Starts the inline header rename. Forwarded for plain/enrichment columns;
35+
* workflow outputs rename through the workflow sidebar. */
36+
onRenameColumn?: (columnName: string) => void
37+
/** Converts the column to another type. Plain/enrichment columns only. */
38+
onChangeType?: (columnName: string, type: ColumnDefinition['type']) => void
39+
/** Opens the config sidebar for types with per-column configuration. */
40+
onConfigure?: (columnName: string) => void
2741
onDeleteColumn: (columnName: string) => void
2842
onResizeStart: (columnKey: string) => void
2943
onResize: (columnKey: string, width: number) => void
@@ -68,12 +82,17 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
6882
isRenaming,
6983
isColumnSelected,
7084
renameValue,
85+
renameError,
7186
onRenameValueChange,
7287
onRenameSubmit,
7388
onRenameCancel,
7489
onColumnSelect,
7590
onInsertLeft,
7691
onInsertRight,
92+
onToggleUnique,
93+
onRenameColumn,
94+
onChangeType,
95+
onConfigure,
7796
onDeleteColumn,
7897
onResizeStart,
7998
onResize,
@@ -115,6 +134,11 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
115134
? 'Hide column'
116135
: 'Delete column'
117136
: undefined
137+
// Workflow outputs never take a unique constraint; enrichment outputs behave
138+
// like plain columns (matching `handleConfigureColumn`'s routing). The type's
139+
// own say-so comes from the registry, never a per-type check here.
140+
const isWorkflowOutput = !!column.workflowGroupId && ownGroup?.type !== 'enrichment'
141+
const supportsUnique = !isWorkflowOutput && columnTypeOf(column).supportsUnique
118142
useEffect(() => {
119143
if (isRenaming && renameInputRef.current) {
120144
renameInputRef.current.focus()
@@ -225,7 +249,10 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
225249
}
226250
if (isRenaming) return
227251
onColumnSelect(colIndex, e.shiftKey)
228-
if (!e.shiftKey) {
252+
// Only workflow-output columns still have a config surface behind a plain
253+
// click (the workflow sidebar). Plain columns edit inline / via the menu,
254+
// so clicking their header just selects the column.
255+
if (!e.shiftKey && isWorkflowOutput) {
229256
onOpenConfig(column.key)
230257
}
231258
}
@@ -295,7 +322,11 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
295322
if (e.key === 'Escape') onRenameCancel()
296323
}}
297324
onBlur={onRenameSubmit}
298-
className='ml-1.5 min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-primary)] text-small outline-none focus:outline-none focus:ring-0'
325+
aria-invalid={renameError || undefined}
326+
className={cn(
327+
'ml-1.5 min-w-0 flex-1 border-0 bg-transparent p-0 text-small outline-none focus:outline-none focus:ring-0',
328+
renameError ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]'
329+
)}
299330
/>
300331
</div>
301332
) : readOnly ? (
@@ -345,9 +376,13 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
345376
position={menuPosition}
346377
column={column}
347378
deleteLabel={deleteLabel}
348-
onOpenConfig={onOpenConfig}
379+
onOpenConfig={isWorkflowOutput ? onOpenConfig : undefined}
380+
onRenameColumn={isWorkflowOutput ? undefined : onRenameColumn}
381+
onChangeType={isWorkflowOutput ? undefined : onChangeType}
382+
onConfigure={isWorkflowOutput ? undefined : onConfigure}
349383
onInsertLeft={onInsertLeft}
350384
onInsertRight={onInsertRight}
385+
onToggleUnique={supportsUnique ? onToggleUnique : undefined}
351386
onDeleteColumn={onDeleteColumn}
352387
onViewWorkflow={
353388
onViewWorkflow && ownGroup ? () => onViewWorkflow(ownGroup.workflowId) : undefined
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use client'
2+
3+
import React, { useEffect, useRef } from 'react'
4+
import { cn } from '@sim/emcn'
5+
import type { ColumnDefinition } from '@/lib/table'
6+
import { ColumnTypeIcon } from './column-type-icon'
7+
8+
interface DraftColumnHeaderProps {
9+
type: ColumnDefinition['type']
10+
name: string
11+
/** True after a refused commit — paints the name red until it's edited. */
12+
invalid: boolean
13+
onNameChange: (name: string) => void
14+
/** Enter or blur. The grid decides whether this persists the column. */
15+
onCommit: () => void
16+
/** Escape. Discards the draft — nothing was ever persisted. */
17+
onCancel: () => void
18+
}
19+
20+
/**
21+
* Header cell for a column that exists only in this browser: the user picked
22+
* a type and is naming it, but nothing is persisted until the name commits
23+
* (or, for a type with configuration, until the sidebar saves). Renders like
24+
* the rename state of a real header so the draft reads as "the column, being
25+
* named" rather than a form. Like the "+ New column" cell it has no body
26+
* cells beneath it.
27+
*/
28+
export const DraftColumnHeader = React.memo(function DraftColumnHeader({
29+
type,
30+
name,
31+
invalid,
32+
onNameChange,
33+
onCommit,
34+
onCancel,
35+
}: DraftColumnHeaderProps) {
36+
const inputRef = useRef<HTMLInputElement>(null)
37+
38+
useEffect(() => {
39+
inputRef.current?.focus()
40+
inputRef.current?.select()
41+
}, [])
42+
43+
return (
44+
<th className='relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle'>
45+
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
46+
<ColumnTypeIcon type={type} />
47+
<input
48+
ref={inputRef}
49+
type='text'
50+
value={name}
51+
aria-label='New column name'
52+
aria-invalid={invalid || undefined}
53+
onChange={(e) => onNameChange(e.target.value)}
54+
onKeyDown={(e) => {
55+
if (e.key === 'Enter') onCommit()
56+
if (e.key === 'Escape') onCancel()
57+
}}
58+
onBlur={onCommit}
59+
className={cn(
60+
'ml-1.5 min-w-0 flex-1 border-0 bg-transparent p-0 text-small outline-none focus:outline-none focus:ring-0',
61+
invalid ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]'
62+
)}
63+
/>
64+
</div>
65+
</th>
66+
)
67+
})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { ColumnHeaderMenu } from './column-header-menu'
22
export { ColumnTypeIcon, columnTypeIcon } from './column-type-icon'
3+
export { DraftColumnHeader } from './draft-column-header'
34
export { ColumnOptionsMenu, WorkflowGroupMetaCell } from './workflow-group-meta-cell'

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,20 @@ import {
2020
ArrowUp,
2121
Eye,
2222
EyeOff,
23+
Fingerprint,
2324
Pencil,
2425
Pin,
2526
PinOff,
2627
PlayOutline,
28+
Settings,
2729
Trash,
2830
Workflow,
2931
X,
3032
} from '@sim/emcn/icons'
3133
import type { RunLimit, RunMode } from '@/lib/api/contracts/tables'
32-
import type { SortDirection, WorkflowGroupType } from '@/lib/table'
34+
import type { ColumnDefinition, SortDirection, WorkflowGroupType } from '@/lib/table'
35+
import { columnTypeOf } from '@/lib/table/column-types'
36+
import { PLAIN_COLUMN_TYPE_OPTIONS } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar'
3337
import { HeaderLabel } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/header-label'
3438
import { getEnrichment } from '@/enrichments/registry'
3539
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
@@ -69,9 +73,24 @@ interface ColumnOptionsMenuProps {
6973
* destructive action is non-lossy (workflow-output column where removing
7074
* it leaves the group with siblings). */
7175
deleteLabel?: string
72-
onOpenConfig: (columnName: string) => void
76+
/** Renders the "Edit column" item. Only workflow-owned columns still have a
77+
* config surface behind it (the workflow sidebar); plain columns edit
78+
* name/type/unique from this menu directly and omit it. */
79+
onOpenConfig?: (columnName: string) => void
80+
/** Starts the inline header rename. Plain and enrichment columns only. */
81+
onRenameColumn?: (columnName: string) => void
82+
/** Converts the column to `type`. Callers route `select` conversions through
83+
* the config sidebar (the option set must be collected first). */
84+
onChangeType?: (columnName: string, type: ColumnDefinition['type']) => void
85+
/** Opens the config sidebar for a type with per-column configuration
86+
* (registry `hasConfiguration`) — a select's options, a currency's code. */
87+
onConfigure?: (columnName: string) => void
7388
onInsertLeft: (columnName: string) => void
7489
onInsertRight: (columnName: string) => void
90+
/** Flip the column's `unique` constraint. Callers pass it only for columns
91+
* that can carry one (plain/enrichment columns of a `supportsUnique` type),
92+
* so the item's presence is the capability check. */
93+
onToggleUnique?: (columnName: string) => void
7594
onDeleteColumn: (columnName: string) => void
7695
/** When provided (i.e. menu opened from a workflow-group meta header), the
7796
* "Delete" item deletes the entire workflow group rather than the single
@@ -112,8 +131,9 @@ interface ColumnOptionsMenuProps {
112131
* Shared column-options dropdown rendered next to the column header chevron
113132
* AND on right-click of the workflow group meta cell. Anchors to a fixed
114133
* position passed in (so callers can place it under the chevron, or at the
115-
* cursor for context-menu use). Rename / change type / unique live in the
116-
* column sidebar (opened by Edit column).
134+
* cursor for context-menu use). Rename, change type, and the unique
135+
* constraint are handled from here; per-type configuration and workflow
136+
* outputs open their sidebars.
117137
*/
118138
export function ColumnOptionsMenu({
119139
open,
@@ -122,8 +142,12 @@ export function ColumnOptionsMenu({
122142
column,
123143
deleteLabel,
124144
onOpenConfig,
145+
onRenameColumn,
146+
onChangeType,
147+
onConfigure,
125148
onInsertLeft,
126149
onInsertRight,
150+
onToggleUnique,
127151
onDeleteColumn,
128152
onDeleteGroup,
129153
onRunColumnAll,
@@ -142,6 +166,8 @@ export function ColumnOptionsMenu({
142166
const showRunActions = Boolean(onRunColumnAll && onRunColumnIncomplete)
143167
const showRunSelected = Boolean(onRunColumnSelected) && selectedRowCount > 0
144168
const runLabels = runMenuLabels(hasActiveFilter)
169+
const typeDefinition = columnTypeOf(column)
170+
const CurrentTypeIcon = typeDefinition.icon
145171
return (
146172
<DropdownMenu open={open} onOpenChange={onOpenChange}>
147173
<DropdownMenuTrigger asChild>
@@ -228,10 +254,53 @@ export function ColumnOptionsMenu({
228254
View workflow
229255
</DropdownMenuItem>
230256
)}
231-
<DropdownMenuItem onSelect={() => onOpenConfig(column.key)}>
232-
<Pencil />
233-
Edit column
234-
</DropdownMenuItem>
257+
{onOpenConfig && (
258+
<DropdownMenuItem onSelect={() => onOpenConfig(column.key)}>
259+
<Pencil />
260+
Edit column
261+
</DropdownMenuItem>
262+
)}
263+
{onRenameColumn && (
264+
<DropdownMenuItem onSelect={() => onRenameColumn(column.key)}>
265+
<Pencil />
266+
Rename column
267+
</DropdownMenuItem>
268+
)}
269+
{onChangeType && (
270+
<DropdownMenuSub>
271+
<DropdownMenuSubTrigger>
272+
<CurrentTypeIcon />
273+
Change type
274+
</DropdownMenuSubTrigger>
275+
<DropdownMenuSubContent>
276+
{PLAIN_COLUMN_TYPE_OPTIONS.map((option) => {
277+
const Icon = option.icon
278+
return (
279+
<DropdownMenuItem
280+
key={option.type}
281+
active={column.type === option.type}
282+
onSelect={() => onChangeType(column.key, option.type)}
283+
>
284+
<Icon />
285+
{option.label}
286+
</DropdownMenuItem>
287+
)
288+
})}
289+
</DropdownMenuSubContent>
290+
</DropdownMenuSub>
291+
)}
292+
{onConfigure && typeDefinition.hasConfiguration && (
293+
<DropdownMenuItem onSelect={() => onConfigure(column.key)}>
294+
<Settings />
295+
{`Configure ${typeDefinition.label.toLowerCase()}`}
296+
</DropdownMenuItem>
297+
)}
298+
{onToggleUnique && (
299+
<DropdownMenuItem onSelect={() => onToggleUnique(column.key)}>
300+
<Fingerprint />
301+
{column.unique ? 'Remove unique' : 'Set unique'}
302+
</DropdownMenuItem>
303+
)}
235304
{onPinToggle && (
236305
<DropdownMenuItem onSelect={() => onPinToggle(column.key)}>
237306
{isPinned ? <PinOff /> : <Pin />}

0 commit comments

Comments
 (0)