Skip to content

Commit 37034a3

Browse files
committed
improvement(tables): restore sidebar column configuration
1 parent f011e66 commit 37034a3

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
@@ -48,11 +48,7 @@ vi.mock('@sim/emcn', () => ({
4848
},
4949
ChipInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
5050
FieldDivider: () => <hr />,
51-
Label: ({ children, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) => (
52-
<label htmlFor={props.htmlFor ?? 'test-field'} {...props}>
53-
{children}
54-
</label>
55-
),
51+
Label: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
5652
Switch: ({ checked }: { checked?: boolean }) => (
5753
<button type='button' aria-pressed={checked}>
5854
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()
@@ -298,7 +305,11 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
298305
if (e.key === 'Escape') onRenameCancel()
299306
}}
300307
onBlur={onRenameSubmit}
301-
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'
308+
aria-invalid={renameError || undefined}
309+
className={cn(
310+
'ml-1.5 min-w-0 flex-1 border-0 bg-transparent p-0 text-small outline-none focus:outline-none focus:ring-0',
311+
renameError ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]'
312+
)}
302313
/>
303314
</div>
304315
) : readOnly ? (
@@ -349,6 +360,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
349360
column={column}
350361
deleteLabel={deleteLabel}
351362
onOpenConfig={onOpenConfig}
363+
onRenameColumn={isWorkflowOutput ? undefined : onRenameColumn}
352364
onGoToReferenceTable={onGoToReferenceTable}
353365
onInsertLeft={onInsertLeft}
354366
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
@@ -74,7 +74,11 @@ afterEach(() => {
7474
container.remove()
7575
})
7676

77-
function renderMenu(column: ColumnDefinition, onGoToReferenceTable: (tableId: string) => void) {
77+
function renderMenu(
78+
column: ColumnDefinition,
79+
onGoToReferenceTable: (tableId: string) => void,
80+
onRenameColumn?: (columnName: string) => void
81+
) {
7882
act(() => {
7983
root.render(
8084
<ColumnOptionsMenu
@@ -92,7 +96,9 @@ function renderMenu(column: ColumnDefinition, onGoToReferenceTable: (tableId: st
9296
onInsertLeft={vi.fn()}
9397
onInsertRight={vi.fn()}
9498
onDeleteColumn={vi.fn()}
99+
onOpenConfig={vi.fn()}
95100
onGoToReferenceTable={onGoToReferenceTable}
101+
onRenameColumn={onRenameColumn}
96102
/>
97103
)
98104
})
@@ -134,3 +140,14 @@ describe('ColumnOptionsMenu Reference navigation', () => {
134140
expect(findButton('Go to Reference Table')).toBeUndefined()
135141
})
136142
})
143+
144+
describe('ColumnOptionsMenu editing', () => {
145+
it('starts inline rename from the column menu', () => {
146+
const onRenameColumn = vi.fn()
147+
renderMenu({ id: 'col-name', name: 'Name', type: 'string' }, vi.fn(), onRenameColumn)
148+
149+
act(() => findButton('Rename column')?.click())
150+
151+
expect(onRenameColumn).toHaveBeenCalledWith('col-name')
152+
})
153+
})

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'
@@ -78,6 +79,7 @@ import {
7879
chipRowCount,
7980
classifyExecStatusMix,
8081
collectRowSnapshots,
82+
columnNameIssue,
8183
computeNormalizedSelection,
8284
drainTargetForChip,
8385
type ExecStatusMix,
@@ -1502,16 +1504,60 @@ export function TableGrid({
15021504
const handleFindCloseRef = useRef(handleFindClose)
15031505
handleFindCloseRef.current = handleFindClose
15041506

1507+
const [renameError, setRenameError] = useState(false)
1508+
15051509
const columnRename = useInlineRename({
15061510
// `columnName` is the column id; record the prior display name + id so undo
15071511
// restores the label (not the id) and targets the right column.
15081512
onSave: (columnName, newName) => {
15091513
const oldName = columnsRef.current.find((c) => c.key === columnName)?.name ?? columnName
15101514
pushUndoRef.current({ type: 'rename-column', oldName, newName, columnId: columnName })
15111515
handleColumnRename(columnName, newName)
1512-
return updateColumnMutation.mutateAsync({ columnName, updates: { name: newName } })
1516+
return updateColumnMutation
1517+
.mutateAsync({ columnName, updates: { name: newName } })
1518+
.catch((error: unknown) => {
1519+
if (isValidationError(error)) {
1520+
toast.error(extractValidationIssues(error)[0]?.message ?? getErrorMessage(error))
1521+
}
1522+
setRenameError(true)
1523+
throw error
1524+
})
15131525
},
15141526
})
1527+
const columnRenameRef = useRef(columnRename)
1528+
columnRenameRef.current = columnRename
1529+
1530+
const handleRenameValueChange = useCallback((value: string) => {
1531+
setRenameError(false)
1532+
columnRenameRef.current.setEditValue(value)
1533+
}, [])
1534+
1535+
/** Keeps invalid names in the header so the user can correct them in place. */
1536+
const handleRenameSubmit = useCallback(() => {
1537+
const { editingId, editValue, submitRename } = columnRenameRef.current
1538+
const trimmedName = editValue.trim()
1539+
const currentColumn = columnsRef.current.find((column) => column.key === editingId)
1540+
if (trimmedName && currentColumn && trimmedName !== currentColumn.name) {
1541+
const issue = columnNameIssue(
1542+
trimmedName,
1543+
schemaColumnsRef.current
1544+
.filter((column) => getColumnId(column) !== editingId)
1545+
.map((column) => column.name)
1546+
)
1547+
if (issue) {
1548+
toast.error(issue)
1549+
setRenameError(true)
1550+
return
1551+
}
1552+
}
1553+
setRenameError(false)
1554+
void submitRename()
1555+
}, [])
1556+
1557+
const handleRenameCancel = useCallback(() => {
1558+
setRenameError(false)
1559+
columnRenameRef.current.cancelRename()
1560+
}, [])
15151561

15161562
const toggleBooleanCell = useCallback(
15171563
(rowId: string, columnName: string, currentValue: unknown) => {
@@ -3987,6 +4033,15 @@ export function TableGrid({
39874033
[onOpenColumnConfig, onOpenWorkflowConfig, workflowGroupById]
39884034
)
39894035

4036+
const handleRenameColumn = useCallback(
4037+
(columnName: string) => {
4038+
setRenameError(false)
4039+
const column = columnsRef.current.find((candidate) => candidate.key === columnName)
4040+
columnRename.startRename(columnName, column?.name ?? columnName)
4041+
},
4042+
[columnRename.startRename]
4043+
)
4044+
39904045
const handleConfigureWorkflowGroup = useCallback(
39914046
(groupId: string) => {
39924047
const group = workflowGroupById.get(groupId)
@@ -4877,9 +4932,10 @@ export function TableGrid({
48774932
renameValue={
48784933
columnRename.editingId === column.key ? columnRename.editValue : ''
48794934
}
4880-
onRenameValueChange={columnRename.setEditValue}
4881-
onRenameSubmit={columnRename.submitRename}
4882-
onRenameCancel={columnRename.cancelRename}
4935+
renameError={renameError && columnRename.editingId === column.key}
4936+
onRenameValueChange={handleRenameValueChange}
4937+
onRenameSubmit={handleRenameSubmit}
4938+
onRenameCancel={handleRenameCancel}
48834939
onColumnSelect={handleColumnSelect}
48844940
// Required props here, and the menu is already
48854941
// suppressed for non-editors by `readOnly`.
@@ -4904,6 +4960,9 @@ export function TableGrid({
49044960
workflowGroups={tableWorkflowGroups}
49054961
sourceInfo={columnSourceInfo.get(column.key)}
49064962
onOpenConfig={handleConfigureColumn}
4963+
onRenameColumn={
4964+
userPermissions.canEdit ? handleRenameColumn : undefined
4965+
}
49074966
onGoToReferenceTable={handleGoToReferenceTable}
49084967
onViewWorkflow={handleViewWorkflow}
49094968
onSortColumn={onSortColumn}

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
horizontalEdgeScrollVelocity,
1718
selectedColumnIds,
@@ -197,3 +198,23 @@ describe('drainTargetForChip', () => {
197198
expect(drainTargetForChip(0)).toBe(MAX_TABLE_SELECTION_ROWS)
198199
})
199200
})
201+
202+
describe('columnNameIssue', () => {
203+
it('accepts a pattern-safe, unused name', () => {
204+
expect(columnNameIssue('email_address', ['name', 'status'])).toBeNull()
205+
})
206+
207+
it('refuses invalid patterns and names that begin with a digit', () => {
208+
expect(columnNameIssue('New Text', [])).toMatch(/letter or underscore/)
209+
expect(columnNameIssue('1st', [])).toMatch(/letter or underscore/)
210+
})
211+
212+
it('refuses a name longer than the column-name limit', () => {
213+
const longName = 'a'.repeat(TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH + 1)
214+
expect(columnNameIssue(longName, [])).toMatch(/characters or less/)
215+
})
216+
217+
it('refuses an existing name case-insensitively', () => {
218+
expect(columnNameIssue('EMAIL', ['email'])).toBe('A column named "email" already exists')
219+
})
220+
})

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
@@ -12,7 +12,7 @@ import type {
1212
WorkflowGroup,
1313
} from '@/lib/table'
1414
import { getColumnId } from '@/lib/table/column-keys'
15-
import { TABLE_LIMITS } from '@/lib/table/constants'
15+
import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants'
1616
import { areGroupDepsSatisfied, areOutputsFilled } from '@/lib/table/deps'
1717
import type { ChatContext } from '@/stores/panel'
1818
import type { DeletedRowSnapshot } from '@/stores/table/types'
@@ -486,3 +486,25 @@ export function canWriteRowsWithChip(opts: {
486486
if (!opts.hasContext || !opts.complete) return false
487487
return opts.rowCount > 0 && opts.rowCount <= TABLE_LIMITS.MAX_COPY_ROWS
488488
}
489+
490+
/**
491+
* Returns a user-facing reason that a proposed column name cannot be saved,
492+
* or `null` when the name is valid and unused.
493+
*
494+
* @param takenNames Names of every other column in the table.
495+
*/
496+
export function columnNameIssue(name: string, takenNames: Iterable<string>): string | null {
497+
if (name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) {
498+
return `Column names must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less`
499+
}
500+
if (!NAME_PATTERN.test(name)) {
501+
return 'Column names must start with a letter or underscore and use only letters, numbers, and underscores'
502+
}
503+
const lowerName = name.toLowerCase()
504+
for (const takenName of takenNames) {
505+
if (takenName.toLowerCase() === lowerName) {
506+
return `A column named "${takenName}" already exists`
507+
}
508+
}
509+
return null
510+
}

0 commit comments

Comments
 (0)