Skip to content

Commit 65659ee

Browse files
committed
improvement(tables): restore sidebar column configuration
1 parent 503183e commit 65659ee

7 files changed

Lines changed: 156 additions & 15 deletions

File tree

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,7 @@ vi.mock('@sim/emcn', () => ({
5151
},
5252
ChipInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
5353
FieldDivider: () => <hr />,
54-
Label: ({ children, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) => (
55-
<label htmlFor={props.htmlFor ?? 'test-field'} {...props}>
56-
{children}
57-
</label>
58-
),
54+
Label: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
5955
Switch: ({ checked }: { checked?: boolean }) => (
6056
<button type='button' aria-pressed={checked}>
6157
Toggle

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,16 @@ interface ColumnHeaderMenuProps {
2222
isRenaming: boolean
2323
isColumnSelected: boolean
2424
renameValue: string
25+
/** Marks a refused inline rename until the user changes or cancels it. */
26+
renameError?: boolean
2527
onRenameValueChange: (value: string) => void
2628
onRenameSubmit: () => void
2729
onRenameCancel: () => void
2830
onColumnSelect: (colIndex: number, shiftKey: boolean) => void
2931
onInsertLeft: (columnName: string) => void
3032
onInsertRight: (columnName: string) => void
33+
/** Starts inline renaming for a plain or enrichment column. */
34+
onRenameColumn?: (columnName: string) => void
3135
/** Opens the table targeted by a Reference column. */
3236
onGoToReferenceTable?: (tableId: string) => void
3337
onDeleteColumn: (columnName: string) => void
@@ -76,12 +80,14 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
7680
isRenaming,
7781
isColumnSelected,
7882
renameValue,
83+
renameError,
7984
onRenameValueChange,
8085
onRenameSubmit,
8186
onRenameCancel,
8287
onColumnSelect,
8388
onInsertLeft,
8489
onInsertRight,
90+
onRenameColumn,
8591
onGoToReferenceTable,
8692
onDeleteColumn,
8793
onResizeStart,
@@ -124,6 +130,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
124130
? 'Hide column'
125131
: 'Delete column'
126132
: undefined
133+
const isWorkflowOutput = Boolean(column.workflowGroupId && ownGroup?.type !== 'enrichment')
127134
useEffect(() => {
128135
if (isRenaming && renameInputRef.current) {
129136
renameInputRef.current.focus()
@@ -305,7 +312,11 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
305312
if (e.key === 'Escape') onRenameCancel()
306313
}}
307314
onBlur={onRenameSubmit}
308-
className='ml-1.5 min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-primary)] text-small outline-hidden focus:outline-hidden focus:ring-0'
315+
aria-invalid={renameError || undefined}
316+
className={cn(
317+
'ml-1.5 min-w-0 flex-1 border-0 bg-transparent p-0 text-small outline-hidden focus:outline-hidden focus:ring-0',
318+
renameError ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]'
319+
)}
309320
/>
310321
</div>
311322
) : readOnly ? (
@@ -370,6 +381,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
370381
onOpenConfig={onOpenConfig}
371382
schemaLockedReason={schemaLockedReason}
372383
deleteLockedReason={deleteLockedReason}
384+
onRenameColumn={isWorkflowOutput ? undefined : onRenameColumn}
373385
onGoToReferenceTable={onGoToReferenceTable}
374386
onInsertLeft={onInsertLeft}
375387
onInsertRight={onInsertRight}

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,11 @@ afterEach(() => {
8484
container.remove()
8585
})
8686

87-
function renderMenu(column: ColumnDefinition, onGoToReferenceTable: (tableId: string) => void) {
87+
function renderMenu(
88+
column: ColumnDefinition,
89+
onGoToReferenceTable: (tableId: string) => void,
90+
onRenameColumn?: (columnName: string) => void
91+
) {
8892
act(() => {
8993
root.render(
9094
<ColumnOptionsMenu
@@ -102,7 +106,9 @@ function renderMenu(column: ColumnDefinition, onGoToReferenceTable: (tableId: st
102106
onInsertLeft={vi.fn()}
103107
onInsertRight={vi.fn()}
104108
onDeleteColumn={vi.fn()}
109+
onOpenConfig={vi.fn()}
105110
onGoToReferenceTable={onGoToReferenceTable}
111+
onRenameColumn={onRenameColumn}
106112
/>
107113
)
108114
})
@@ -196,3 +202,14 @@ describe('ColumnHeaderMenu read-only Reference navigation', () => {
196202
expect(container.querySelector('button[aria-label="Column options"]')).toBeNull()
197203
})
198204
})
205+
206+
describe('ColumnOptionsMenu editing', () => {
207+
it('starts inline rename from the column menu', () => {
208+
const onRenameColumn = vi.fn()
209+
renderMenu({ id: 'col-name', name: 'Name', type: 'string' }, vi.fn(), onRenameColumn)
210+
211+
act(() => findButton('Rename column')?.click())
212+
213+
expect(onRenameColumn).toHaveBeenCalledWith('col-name')
214+
})
215+
})

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ interface ColumnOptionsMenuProps {
7676
schemaLockedReason?: string
7777
/** Why deleting is unavailable; disables the destructive column row. */
7878
deleteLockedReason?: string
79+
/** Starts inline renaming for a plain or enrichment column. */
80+
onRenameColumn?: (columnName: string) => void
7981
/** Opens the table targeted by a Reference column. */
8082
onGoToReferenceTable?: (tableId: string) => void
8183
onInsertLeft: (columnName: string) => void
@@ -137,9 +139,9 @@ function MenuRow({ reason, children }: { reason?: string; children: React.ReactE
137139
/**
138140
* Shared column-options dropdown rendered next to the column header chevron
139141
* AND on right-click of the workflow group meta cell. Anchors to a fixed
140-
* position passed in (so callers can place it under the chevron, or at the
141-
* cursor for context-menu use). Rename / change type / unique live in the
142-
* column sidebar (opened by Edit column).
142+
* position passed in so callers can place it under the chevron or at the
143+
* cursor. Rename starts in the header; type, uniqueness, and type-specific
144+
* configuration live in the sidebar opened by Edit column.
143145
*/
144146
export function ColumnOptionsMenu({
145147
open,
@@ -150,6 +152,7 @@ export function ColumnOptionsMenu({
150152
onOpenConfig,
151153
schemaLockedReason,
152154
deleteLockedReason,
155+
onRenameColumn,
153156
onGoToReferenceTable,
154157
onInsertLeft,
155158
onInsertRight,
@@ -276,6 +279,17 @@ export function ColumnOptionsMenu({
276279
Edit column
277280
</DropdownMenuItem>
278281
</MenuRow>
282+
{onRenameColumn && (
283+
<MenuRow reason={schemaLockedReason}>
284+
<DropdownMenuItem
285+
disabled={Boolean(schemaLockedReason)}
286+
onSelect={() => onRenameColumn(column.key)}
287+
>
288+
<Pencil />
289+
Rename column
290+
</DropdownMenuItem>
291+
</MenuRow>
292+
)}
279293
{onPinToggle && (
280294
<DropdownMenuItem onSelect={() => onPinToggle(column.key)}>
281295
{isPinned ? <PinOff /> : <Pin />}

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx‎

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { assessTextPaste, formatPasteLimit, PASTE_LIMITS } from '@sim/utils/past
1111
import { useVirtualizer } from '@tanstack/react-virtual'
1212
import { useParams, useRouter } from 'next/navigation'
1313
import { usePostHog } from 'posthog-js/react'
14+
import { extractValidationIssues, isValidationError } from '@/lib/api/client/errors'
1415
import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables'
1516
import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard'
1617
import { captureEvent } from '@/lib/posthog/client'
@@ -93,6 +94,7 @@ import {
9394
chipRowCount,
9495
classifyExecStatusMix,
9596
collectRowSnapshots,
97+
columnNameIssue,
9698
computeNormalizedSelection,
9799
drainTargetForChip,
98100
type ExecStatusMix,
@@ -1583,16 +1585,60 @@ export function TableGrid({
15831585
const handleFindCloseRef = useRef(handleFindClose)
15841586
handleFindCloseRef.current = handleFindClose
15851587

1588+
const [renameError, setRenameError] = useState(false)
1589+
15861590
const columnRename = useInlineRename({
15871591
// `columnName` is the column id; record the prior display name + id so undo
15881592
// restores the label (not the id) and targets the right column.
15891593
onSave: (columnName, newName) => {
15901594
const oldName = columnsRef.current.find((c) => c.key === columnName)?.name ?? columnName
15911595
pushUndoRef.current({ type: 'rename-column', oldName, newName, columnId: columnName })
15921596
handleColumnRename(columnName, newName)
1593-
return updateColumnMutation.mutateAsync({ columnName, updates: { name: newName } })
1597+
return updateColumnMutation
1598+
.mutateAsync({ columnName, updates: { name: newName } })
1599+
.catch((error: unknown) => {
1600+
if (isValidationError(error)) {
1601+
toast.error(extractValidationIssues(error)[0]?.message ?? getErrorMessage(error))
1602+
}
1603+
setRenameError(true)
1604+
throw error
1605+
})
15941606
},
15951607
})
1608+
const columnRenameRef = useRef(columnRename)
1609+
columnRenameRef.current = columnRename
1610+
1611+
const handleRenameValueChange = useCallback((value: string) => {
1612+
setRenameError(false)
1613+
columnRenameRef.current.setEditValue(value)
1614+
}, [])
1615+
1616+
/** Keeps invalid names in the header so the user can correct them in place. */
1617+
const handleRenameSubmit = useCallback(() => {
1618+
const { editingId, editValue, submitRename } = columnRenameRef.current
1619+
const trimmedName = editValue.trim()
1620+
const currentColumn = columnsRef.current.find((column) => column.key === editingId)
1621+
if (trimmedName && currentColumn && trimmedName !== currentColumn.name) {
1622+
const issue = columnNameIssue(
1623+
trimmedName,
1624+
schemaColumnsRef.current
1625+
.filter((column) => getColumnId(column) !== editingId)
1626+
.map((column) => column.name)
1627+
)
1628+
if (issue) {
1629+
toast.error(issue)
1630+
setRenameError(true)
1631+
return
1632+
}
1633+
}
1634+
setRenameError(false)
1635+
void submitRename()
1636+
}, [])
1637+
1638+
const handleRenameCancel = useCallback(() => {
1639+
setRenameError(false)
1640+
columnRenameRef.current.cancelRename()
1641+
}, [])
15961642

15971643
const toggleBooleanCell = useCallback(
15981644
(rowId: string, columnName: string, currentValue: unknown) => {
@@ -4125,6 +4171,15 @@ export function TableGrid({
41254171
[onOpenColumnConfig, onOpenWorkflowConfig, workflowGroupById]
41264172
)
41274173

4174+
const handleRenameColumn = useCallback(
4175+
(columnName: string) => {
4176+
setRenameError(false)
4177+
const column = columnsRef.current.find((candidate) => candidate.key === columnName)
4178+
columnRename.startRename(columnName, column?.name ?? columnName)
4179+
},
4180+
[columnRename.startRename]
4181+
)
4182+
41284183
const handleConfigureWorkflowGroup = useCallback(
41294184
(groupId: string) => {
41304185
const group = workflowGroupById.get(groupId)
@@ -5021,9 +5076,10 @@ export function TableGrid({
50215076
renameValue={
50225077
columnRename.editingId === column.key ? columnRename.editValue : ''
50235078
}
5024-
onRenameValueChange={columnRename.setEditValue}
5025-
onRenameSubmit={columnRename.submitRename}
5026-
onRenameCancel={columnRename.cancelRename}
5079+
renameError={renameError && columnRename.editingId === column.key}
5080+
onRenameValueChange={handleRenameValueChange}
5081+
onRenameSubmit={handleRenameSubmit}
5082+
onRenameCancel={handleRenameCancel}
50275083
onColumnSelect={handleColumnSelect}
50285084
// Required props here, and the menu is already
50295085
// suppressed for non-editors by `readOnly`.
@@ -5054,6 +5110,9 @@ export function TableGrid({
50545110
deleteLockedReason={
50555111
locks?.deleteLocked ? LOCK_TOOLTIPS.delete : undefined
50565112
}
5113+
onRenameColumn={
5114+
userPermissions.canEdit ? handleRenameColumn : undefined
5115+
}
50575116
onGoToReferenceTable={
50585117
referenceColumnsEnabled ? handleGoToReferenceTable : undefined
50595118
}

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
buildTableSelectionContext,
1313
canWriteRowsWithChip,
1414
chipRowCount,
15+
columnNameIssue,
1516
drainTargetForChip,
1617
expandToDisplayColumns,
1718
horizontalEdgeScrollVelocity,
@@ -235,3 +236,23 @@ describe('drainTargetForChip', () => {
235236
expect(drainTargetForChip(0)).toBe(MAX_TABLE_SELECTION_ROWS)
236237
})
237238
})
239+
240+
describe('columnNameIssue', () => {
241+
it('accepts a pattern-safe, unused name', () => {
242+
expect(columnNameIssue('email_address', ['name', 'status'])).toBeNull()
243+
})
244+
245+
it('refuses invalid patterns and names that begin with a digit', () => {
246+
expect(columnNameIssue('New Text', [])).toMatch(/letter or underscore/)
247+
expect(columnNameIssue('1st', [])).toMatch(/letter or underscore/)
248+
})
249+
250+
it('refuses a name longer than the column-name limit', () => {
251+
const longName = 'a'.repeat(TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH + 1)
252+
expect(columnNameIssue(longName, [])).toMatch(/characters or less/)
253+
})
254+
255+
it('refuses an existing name case-insensitively', () => {
256+
expect(columnNameIssue('EMAIL', ['email'])).toBe('A column named "email" already exists')
257+
})
258+
})

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type {
1313
} from '@/lib/table'
1414
import { getColumnId } from '@/lib/table/column-keys'
1515
import { columnReferencedTableIds } from '@/lib/table/column-types'
16-
import { TABLE_LIMITS } from '@/lib/table/constants'
16+
import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants'
1717
import { areGroupDepsSatisfied, areOutputsFilled } from '@/lib/table/deps'
1818
import type {
1919
DisplayColumn,
@@ -513,3 +513,25 @@ export function canWriteRowsWithChip(opts: {
513513
if (!opts.hasContext || !opts.complete) return false
514514
return opts.rowCount > 0 && opts.rowCount <= TABLE_LIMITS.MAX_COPY_ROWS
515515
}
516+
517+
/**
518+
* Returns a user-facing reason that a proposed column name cannot be saved,
519+
* or `null` when the name is valid and unused.
520+
*
521+
* @param takenNames Names of every other column in the table.
522+
*/
523+
export function columnNameIssue(name: string, takenNames: Iterable<string>): string | null {
524+
if (name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) {
525+
return `Column names must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less`
526+
}
527+
if (!NAME_PATTERN.test(name)) {
528+
return 'Column names must start with a letter or underscore and use only letters, numbers, and underscores'
529+
}
530+
const lowerName = name.toLowerCase()
531+
for (const takenName of takenNames) {
532+
if (takenName.toLowerCase() === lowerName) {
533+
return `A column named "${takenName}" already exists`
534+
}
535+
}
536+
return null
537+
}

0 commit comments

Comments
 (0)