Skip to content

Commit ea62a43

Browse files
committed
improvement(tables): restore sidebar column configuration
1 parent f9e8162 commit ea62a43

9 files changed

Lines changed: 157 additions & 19 deletions

File tree

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +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 {...props}>{children}</label>
53-
),
51+
Label: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
5452
Switch: ({ checked }: { checked?: boolean }) => (
5553
<button type='button' aria-pressed={checked}>
5654
Toggle
@@ -71,7 +69,7 @@ vi.mock('@/lib/table/column-types', () => ({
7169
{ id: 'select', label: 'Select', icon: () => null },
7270
{ id: 'reference', label: 'Reference', icon: () => null },
7371
],
74-
columnTypeOf: (type: string) => ({ supportsUnique: type !== 'select' }),
72+
columnTypeById: (type: string) => ({ supportsUnique: type !== 'select' }),
7573
}))
7674

7775
vi.mock('@/hooks/queries/tables', () => ({

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { X } from '@sim/emcn/icons'
66
import { toError } from '@sim/utils/errors'
77
import { findValidationIssue, isValidationError } from '@/lib/api/client/errors'
88
import type { ColumnDefinition, SelectOption } from '@/lib/table'
9+
import { columnTypeById } from '@/lib/table/column-types'
910
import {
1011
DEFAULT_CURRENCY_CODE,
1112
getCurrencyOptions,
@@ -16,7 +17,6 @@ import {
1617
RequiredLabel,
1718
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields'
1819
import { useAddTableColumn, useTablesList, useUpdateColumn } from '@/hooks/queries/tables'
19-
import { columnTypeOf } from '@/lib/table/column-types'
2020
import { SelectOptionsEditor } from '../select-field'
2121
import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types'
2222

@@ -139,7 +139,7 @@ function ColumnConfigBody({
139139
const wantsOptions = isSelectType(typeInput)
140140
const wantsCurrency = typeInput === 'currency'
141141
const wantsReference = typeInput === 'reference'
142-
const supportsUnique = columnTypeOf(typeInput).supportsUnique
142+
const supportsUnique = columnTypeById(typeInput).supportsUnique
143143
const { data: workspaceTables = [] } = useTablesList(workspaceId, 'active', {
144144
enabled: wantsReference,
145145
})

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type { ColumnConfig, ColumnConfigurationMetadata } from './column-config-sidebar'
1+
export type { ColumnConfig } from './column-config-sidebar'
22
export { ColumnConfigSidebar } from './column-config-sidebar'
33
export {
44
COLUMN_TYPE_OPTIONS,

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
@@ -75,7 +75,11 @@ afterEach(() => {
7575
container.remove()
7676
})
7777

78-
function renderMenu(column: ColumnDefinition, onGoToReferenceTable: (tableId: string) => void) {
78+
function renderMenu(
79+
column: ColumnDefinition,
80+
onGoToReferenceTable: (tableId: string) => void,
81+
onRenameColumn?: (columnName: string) => void
82+
) {
7983
act(() => {
8084
root.render(
8185
<ColumnOptionsMenu
@@ -93,7 +97,9 @@ function renderMenu(column: ColumnDefinition, onGoToReferenceTable: (tableId: st
9397
onInsertLeft={vi.fn()}
9498
onInsertRight={vi.fn()}
9599
onDeleteColumn={vi.fn()}
100+
onOpenConfig={vi.fn()}
96101
onGoToReferenceTable={onGoToReferenceTable}
102+
onRenameColumn={onRenameColumn}
97103
/>
98104
)
99105
})
@@ -135,3 +141,14 @@ describe('ColumnOptionsMenu Reference navigation', () => {
135141
expect(findButton('Go to Reference Table')).toBeUndefined()
136142
})
137143
})
144+
145+
describe('ColumnOptionsMenu editing', () => {
146+
it('starts inline rename from the column menu', () => {
147+
const onRenameColumn = vi.fn()
148+
renderMenu({ id: 'col-name', name: 'Name', type: 'string' }, vi.fn(), onRenameColumn)
149+
150+
act(() => findButton('Rename column')?.click())
151+
152+
expect(onRenameColumn).toHaveBeenCalledWith('col-name')
153+
})
154+
})

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

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,12 @@ interface ColumnOptionsMenuProps {
6767
column: DisplayColumn
6868
/** Override for the destructive item's label. Defaults to "Delete column"
6969
* for both plain columns and workflow groups. Use "Hide column" when the
70-
* destructive action is non-lossy (workflow-output column where removing
71-
* it leaves the group with siblings). */
70+
* destructive action is non-lossy (workflow-output column where removing
71+
* 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
@@ -10,6 +10,7 @@ import { getErrorMessage } from '@sim/utils/errors'
1010
import { useVirtualizer } from '@tanstack/react-virtual'
1111
import { useParams, useRouter } from 'next/navigation'
1212
import { usePostHog } from 'posthog-js/react'
13+
import { extractValidationIssues, isValidationError } from '@/lib/api/client/errors'
1314
import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables'
1415
import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard'
1516
import { captureEvent } from '@/lib/posthog/client'
@@ -75,6 +76,7 @@ import {
7576
chipRowCount,
7677
classifyExecStatusMix,
7778
collectRowSnapshots,
79+
columnNameIssue,
7880
computeNormalizedSelection,
7981
drainTargetForChip,
8082
type ExecStatusMix,
@@ -1481,16 +1483,60 @@ export function TableGrid({
14811483
const handleFindCloseRef = useRef(handleFindClose)
14821484
handleFindCloseRef.current = handleFindClose
14831485

1486+
const [renameError, setRenameError] = useState(false)
1487+
14841488
const columnRename = useInlineRename({
14851489
// `columnName` is the column id; record the prior display name + id so undo
14861490
// restores the label (not the id) and targets the right column.
14871491
onSave: (columnName, newName) => {
14881492
const oldName = columnsRef.current.find((c) => c.key === columnName)?.name ?? columnName
14891493
pushUndoRef.current({ type: 'rename-column', oldName, newName, columnId: columnName })
14901494
handleColumnRename(columnName, newName)
1491-
return updateColumnMutation.mutateAsync({ columnName, updates: { name: newName } })
1495+
return updateColumnMutation
1496+
.mutateAsync({ columnName, updates: { name: newName } })
1497+
.catch((error: unknown) => {
1498+
if (isValidationError(error)) {
1499+
toast.error(extractValidationIssues(error)[0]?.message ?? getErrorMessage(error))
1500+
}
1501+
setRenameError(true)
1502+
throw error
1503+
})
14921504
},
14931505
})
1506+
const columnRenameRef = useRef(columnRename)
1507+
columnRenameRef.current = columnRename
1508+
1509+
const handleRenameValueChange = useCallback((value: string) => {
1510+
setRenameError(false)
1511+
columnRenameRef.current.setEditValue(value)
1512+
}, [])
1513+
1514+
/** Keeps invalid names in the header so the user can correct them in place. */
1515+
const handleRenameSubmit = useCallback(() => {
1516+
const { editingId, editValue, submitRename } = columnRenameRef.current
1517+
const trimmedName = editValue.trim()
1518+
const currentColumn = columnsRef.current.find((column) => column.key === editingId)
1519+
if (trimmedName && currentColumn && trimmedName !== currentColumn.name) {
1520+
const issue = columnNameIssue(
1521+
trimmedName,
1522+
schemaColumnsRef.current
1523+
.filter((column) => getColumnId(column) !== editingId)
1524+
.map((column) => column.name)
1525+
)
1526+
if (issue) {
1527+
toast.error(issue)
1528+
setRenameError(true)
1529+
return
1530+
}
1531+
}
1532+
setRenameError(false)
1533+
void submitRename()
1534+
}, [])
1535+
1536+
const handleRenameCancel = useCallback(() => {
1537+
setRenameError(false)
1538+
columnRenameRef.current.cancelRename()
1539+
}, [])
14941540

14951541
const toggleBooleanCell = useCallback(
14961542
(rowId: string, columnName: string, currentValue: unknown) => {
@@ -3922,6 +3968,15 @@ export function TableGrid({
39223968
[onOpenColumnConfig, onOpenWorkflowConfig, workflowGroupById]
39233969
)
39243970

3971+
const handleRenameColumn = useCallback(
3972+
(columnName: string) => {
3973+
setRenameError(false)
3974+
const column = columnsRef.current.find((candidate) => candidate.key === columnName)
3975+
columnRename.startRename(columnName, column?.name ?? columnName)
3976+
},
3977+
[columnRename.startRename]
3978+
)
3979+
39253980
const handleConfigureWorkflowGroup = useCallback(
39263981
(groupId: string) => {
39273982
const group = workflowGroupById.get(groupId)
@@ -4801,9 +4856,10 @@ export function TableGrid({
48014856
renameValue={
48024857
columnRename.editingId === column.key ? columnRename.editValue : ''
48034858
}
4804-
onRenameValueChange={columnRename.setEditValue}
4805-
onRenameSubmit={columnRename.submitRename}
4806-
onRenameCancel={columnRename.cancelRename}
4859+
renameError={renameError && columnRename.editingId === column.key}
4860+
onRenameValueChange={handleRenameValueChange}
4861+
onRenameSubmit={handleRenameSubmit}
4862+
onRenameCancel={handleRenameCancel}
48074863
onColumnSelect={handleColumnSelect}
48084864
// Required props here, and the menu is already
48094865
// suppressed for non-editors by `readOnly`.
@@ -4828,6 +4884,9 @@ export function TableGrid({
48284884
workflowGroups={tableWorkflowGroups}
48294885
sourceInfo={columnSourceInfo.get(column.key)}
48304886
onOpenConfig={handleConfigureColumn}
4887+
onRenameColumn={
4888+
userPermissions.canEdit ? handleRenameColumn : undefined
4889+
}
48314890
onGoToReferenceTable={handleGoToReferenceTable}
48324891
onViewWorkflow={handleViewWorkflow}
48334892
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)