Skip to content

Commit 62968bf

Browse files
committed
improvement(tables): restore sidebar column configuration
1 parent 815b6c3 commit 62968bf

7 files changed

Lines changed: 151 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
@@ -18,12 +18,16 @@ interface ColumnHeaderMenuProps {
1818
isRenaming: boolean
1919
isColumnSelected: boolean
2020
renameValue: string
21+
/** Marks a refused inline rename until the user changes or cancels it. */
22+
renameError?: boolean
2123
onRenameValueChange: (value: string) => void
2224
onRenameSubmit: () => void
2325
onRenameCancel: () => void
2426
onColumnSelect: (colIndex: number, shiftKey: boolean) => void
2527
onInsertLeft: (columnName: string) => void
2628
onInsertRight: (columnName: string) => void
29+
/** Starts inline renaming for a plain or enrichment column. */
30+
onRenameColumn?: (columnName: string) => void
2731
/** Opens the table targeted by a Reference column. */
2832
onGoToReferenceTable?: (tableId: string) => void
2933
onDeleteColumn: (columnName: string) => void
@@ -70,12 +74,14 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
7074
isRenaming,
7175
isColumnSelected,
7276
renameValue,
77+
renameError,
7378
onRenameValueChange,
7479
onRenameSubmit,
7580
onRenameCancel,
7681
onColumnSelect,
7782
onInsertLeft,
7883
onInsertRight,
84+
onRenameColumn,
7985
onGoToReferenceTable,
8086
onDeleteColumn,
8187
onResizeStart,
@@ -118,6 +124,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
118124
? 'Hide column'
119125
: 'Delete column'
120126
: undefined
127+
const isWorkflowOutput = Boolean(column.workflowGroupId && ownGroup?.type !== 'enrichment')
121128
useEffect(() => {
122129
if (isRenaming && renameInputRef.current) {
123130
renameInputRef.current.focus()
@@ -299,7 +306,11 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
299306
if (e.key === 'Escape') onRenameCancel()
300307
}}
301308
onBlur={onRenameSubmit}
302-
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'
309+
aria-invalid={renameError || undefined}
310+
className={cn(
311+
'ml-1.5 min-w-0 flex-1 border-0 bg-transparent p-0 text-small outline-hidden focus:outline-hidden focus:ring-0',
312+
renameError ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]'
313+
)}
303314
/>
304315
</div>
305316
) : readOnly ? (
@@ -362,6 +373,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
362373
column={column}
363374
deleteLabel={deleteLabel}
364375
onOpenConfig={onOpenConfig}
376+
onRenameColumn={isWorkflowOutput ? undefined : onRenameColumn}
365377
onGoToReferenceTable={onGoToReferenceTable}
366378
onInsertLeft={onInsertLeft}
367379
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: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ interface ColumnOptionsMenuProps {
7171
* it leaves the group with siblings). */
7272
deleteLabel?: string
7373
onOpenConfig: (columnName: string) => void
74+
/** Starts inline renaming for a plain or enrichment column. */
75+
onRenameColumn?: (columnName: string) => void
7476
/** Opens the table targeted by a Reference column. */
7577
onGoToReferenceTable?: (tableId: string) => void
7678
onInsertLeft: (columnName: string) => void
@@ -114,9 +116,9 @@ interface ColumnOptionsMenuProps {
114116
/**
115117
* Shared column-options dropdown rendered next to the column header chevron
116118
* AND on right-click of the workflow group meta cell. Anchors to a fixed
117-
* position passed in (so callers can place it under the chevron, or at the
118-
* cursor for context-menu use). Rename / change type / unique live in the
119-
* column sidebar (opened by Edit column).
119+
* position passed in so callers can place it under the chevron or at the
120+
* cursor. Rename starts in the header; type, uniqueness, and type-specific
121+
* configuration live in the sidebar opened by Edit column.
120122
*/
121123
export function ColumnOptionsMenu({
122124
open,
@@ -125,6 +127,7 @@ export function ColumnOptionsMenu({
125127
column,
126128
deleteLabel,
127129
onOpenConfig,
130+
onRenameColumn,
128131
onGoToReferenceTable,
129132
onInsertLeft,
130133
onInsertRight,
@@ -243,6 +246,12 @@ export function ColumnOptionsMenu({
243246
<Pencil />
244247
Edit column
245248
</DropdownMenuItem>
249+
{onRenameColumn && (
250+
<DropdownMenuItem onSelect={() => onRenameColumn(column.key)}>
251+
<Pencil />
252+
Rename column
253+
</DropdownMenuItem>
254+
)}
246255
{onPinToggle && (
247256
<DropdownMenuItem onSelect={() => onPinToggle(column.key)}>
248257
{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'
@@ -91,6 +92,7 @@ import {
9192
chipRowCount,
9293
classifyExecStatusMix,
9394
collectRowSnapshots,
95+
columnNameIssue,
9496
computeNormalizedSelection,
9597
drainTargetForChip,
9698
type ExecStatusMix,
@@ -1568,16 +1570,60 @@ export function TableGrid({
15681570
const handleFindCloseRef = useRef(handleFindClose)
15691571
handleFindCloseRef.current = handleFindClose
15701572

1573+
const [renameError, setRenameError] = useState(false)
1574+
15711575
const columnRename = useInlineRename({
15721576
// `columnName` is the column id; record the prior display name + id so undo
15731577
// restores the label (not the id) and targets the right column.
15741578
onSave: (columnName, newName) => {
15751579
const oldName = columnsRef.current.find((c) => c.key === columnName)?.name ?? columnName
15761580
pushUndoRef.current({ type: 'rename-column', oldName, newName, columnId: columnName })
15771581
handleColumnRename(columnName, newName)
1578-
return updateColumnMutation.mutateAsync({ columnName, updates: { name: newName } })
1582+
return updateColumnMutation
1583+
.mutateAsync({ columnName, updates: { name: newName } })
1584+
.catch((error: unknown) => {
1585+
if (isValidationError(error)) {
1586+
toast.error(extractValidationIssues(error)[0]?.message ?? getErrorMessage(error))
1587+
}
1588+
setRenameError(true)
1589+
throw error
1590+
})
15791591
},
15801592
})
1593+
const columnRenameRef = useRef(columnRename)
1594+
columnRenameRef.current = columnRename
1595+
1596+
const handleRenameValueChange = useCallback((value: string) => {
1597+
setRenameError(false)
1598+
columnRenameRef.current.setEditValue(value)
1599+
}, [])
1600+
1601+
/** Keeps invalid names in the header so the user can correct them in place. */
1602+
const handleRenameSubmit = useCallback(() => {
1603+
const { editingId, editValue, submitRename } = columnRenameRef.current
1604+
const trimmedName = editValue.trim()
1605+
const currentColumn = columnsRef.current.find((column) => column.key === editingId)
1606+
if (trimmedName && currentColumn && trimmedName !== currentColumn.name) {
1607+
const issue = columnNameIssue(
1608+
trimmedName,
1609+
schemaColumnsRef.current
1610+
.filter((column) => getColumnId(column) !== editingId)
1611+
.map((column) => column.name)
1612+
)
1613+
if (issue) {
1614+
toast.error(issue)
1615+
setRenameError(true)
1616+
return
1617+
}
1618+
}
1619+
setRenameError(false)
1620+
void submitRename()
1621+
}, [])
1622+
1623+
const handleRenameCancel = useCallback(() => {
1624+
setRenameError(false)
1625+
columnRenameRef.current.cancelRename()
1626+
}, [])
15811627

15821628
const toggleBooleanCell = useCallback(
15831629
(rowId: string, columnName: string, currentValue: unknown) => {
@@ -4071,6 +4117,15 @@ export function TableGrid({
40714117
[onOpenColumnConfig, onOpenWorkflowConfig, workflowGroupById]
40724118
)
40734119

4120+
const handleRenameColumn = useCallback(
4121+
(columnName: string) => {
4122+
setRenameError(false)
4123+
const column = columnsRef.current.find((candidate) => candidate.key === columnName)
4124+
columnRename.startRename(columnName, column?.name ?? columnName)
4125+
},
4126+
[columnRename.startRename]
4127+
)
4128+
40744129
const handleConfigureWorkflowGroup = useCallback(
40754130
(groupId: string) => {
40764131
const group = workflowGroupById.get(groupId)
@@ -4961,9 +5016,10 @@ export function TableGrid({
49615016
renameValue={
49625017
columnRename.editingId === column.key ? columnRename.editValue : ''
49635018
}
4964-
onRenameValueChange={columnRename.setEditValue}
4965-
onRenameSubmit={columnRename.submitRename}
4966-
onRenameCancel={columnRename.cancelRename}
5019+
renameError={renameError && columnRename.editingId === column.key}
5020+
onRenameValueChange={handleRenameValueChange}
5021+
onRenameSubmit={handleRenameSubmit}
5022+
onRenameCancel={handleRenameCancel}
49675023
onColumnSelect={handleColumnSelect}
49685024
// Required props here, and the menu is already
49695025
// suppressed for non-editors by `readOnly`.
@@ -4988,6 +5044,9 @@ export function TableGrid({
49885044
workflowGroups={tableWorkflowGroups}
49895045
sourceInfo={columnSourceInfo.get(column.key)}
49905046
onOpenConfig={handleConfigureColumn}
5047+
onRenameColumn={
5048+
userPermissions.canEdit ? handleRenameColumn : undefined
5049+
}
49915050
onGoToReferenceTable={
49925051
referenceColumnsEnabled ? handleGoToReferenceTable : undefined
49935052
}

‎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)