Skip to content

Commit 4735245

Browse files
committed
feat(tables): inline cell editing with optimistic updates
1 parent aac9e74 commit 4735245

File tree

10 files changed

+441
-112
lines changed

10 files changed

+441
-112
lines changed
Lines changed: 97 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,65 @@
11
import type { ColumnDefinition } from '@/lib/table'
22
import { STRING_TRUNCATE_LENGTH } from '../lib/constants'
33
import type { CellViewerData } from '../lib/types'
4+
import { InlineCellEditor } from './inline-cell-editor'
45

56
interface CellRendererProps {
67
value: unknown
78
column: ColumnDefinition
9+
isEditing: boolean
810
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
11+
onDoubleClick: () => void
12+
onSave: (value: unknown) => void
13+
onCancel: () => void
14+
onBooleanToggle: () => void
915
}
1016

11-
export function CellRenderer({ value, column, onCellClick }: CellRendererProps) {
17+
export function CellRenderer({
18+
value,
19+
column,
20+
isEditing,
21+
onCellClick,
22+
onDoubleClick,
23+
onSave,
24+
onCancel,
25+
onBooleanToggle,
26+
}: CellRendererProps) {
27+
if (isEditing) {
28+
return <InlineCellEditor value={value} column={column} onSave={onSave} onCancel={onCancel} />
29+
}
30+
1231
const isNull = value === null || value === undefined
1332

33+
if (column.type === 'boolean') {
34+
const boolValue = Boolean(value)
35+
return (
36+
<button
37+
type='button'
38+
className='cursor-pointer select-none'
39+
onClick={(e) => {
40+
e.stopPropagation()
41+
onBooleanToggle()
42+
}}
43+
>
44+
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
45+
{isNull ? <span className='text-[var(--text-muted)] italic'></span> : boolValue ? 'true' : 'false'}
46+
</span>
47+
</button>
48+
)
49+
}
50+
1451
if (isNull) {
15-
return <span className='text-[var(--text-muted)] italic'></span>
52+
return (
53+
<span
54+
className='text-[var(--text-muted)] italic cursor-text'
55+
onDoubleClick={(e) => {
56+
e.stopPropagation()
57+
onDoubleClick()
58+
}}
59+
>
60+
61+
</span>
62+
)
1663
}
1764

1865
if (column.type === 'json') {
@@ -26,25 +73,29 @@ export function CellRenderer({ value, column, onCellClick }: CellRendererProps)
2673
e.stopPropagation()
2774
onCellClick(column.name, value, 'json')
2875
}}
29-
title='Click to view full JSON'
76+
onDoubleClick={(e) => {
77+
e.preventDefault()
78+
e.stopPropagation()
79+
onDoubleClick()
80+
}}
81+
title='Click to view, double-click to edit'
3082
>
3183
{jsonStr}
3284
</button>
3385
)
3486
}
3587

36-
if (column.type === 'boolean') {
37-
const boolValue = Boolean(value)
38-
return (
39-
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
40-
{boolValue ? 'true' : 'false'}
41-
</span>
42-
)
43-
}
44-
4588
if (column.type === 'number') {
4689
return (
47-
<span className='font-mono text-[12px] text-[var(--text-secondary)]'>{String(value)}</span>
90+
<span
91+
className='font-mono text-[12px] text-[var(--text-secondary)] cursor-text'
92+
onDoubleClick={(e) => {
93+
e.stopPropagation()
94+
onDoubleClick()
95+
}}
96+
>
97+
{String(value)}
98+
</span>
4899
)
49100
}
50101

@@ -59,21 +110,28 @@ export function CellRenderer({ value, column, onCellClick }: CellRendererProps)
59110
minute: '2-digit',
60111
})
61112
return (
62-
<button
63-
type='button'
64-
className='cursor-pointer select-none text-left text-[12px] text-[var(--text-secondary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:text-[var(--text-primary)] hover:decoration-[var(--text-muted)]'
65-
onClick={(e) => {
66-
e.preventDefault()
113+
<span
114+
className='cursor-text text-[12px] text-[var(--text-secondary)]'
115+
onDoubleClick={(e) => {
67116
e.stopPropagation()
68-
onCellClick(column.name, value, 'date')
117+
onDoubleClick()
69118
}}
70-
title='Click to view ISO format'
71119
>
72120
{formatted}
73-
</button>
121+
</span>
74122
)
75123
} catch {
76-
return <span className='text-[var(--text-primary)]'>{String(value)}</span>
124+
return (
125+
<span
126+
className='text-[var(--text-primary)] cursor-text'
127+
onDoubleClick={(e) => {
128+
e.stopPropagation()
129+
onDoubleClick()
130+
}}
131+
>
132+
{String(value)}
133+
</span>
134+
)
77135
}
78136
}
79137

@@ -88,12 +146,27 @@ export function CellRenderer({ value, column, onCellClick }: CellRendererProps)
88146
e.stopPropagation()
89147
onCellClick(column.name, value, 'text')
90148
}}
91-
title='Click to view full text'
149+
onDoubleClick={(e) => {
150+
e.preventDefault()
151+
e.stopPropagation()
152+
onDoubleClick()
153+
}}
154+
title='Click to view, double-click to edit'
92155
>
93156
{strValue}
94157
</button>
95158
)
96159
}
97160

98-
return <span className='text-[var(--text-primary)]'>{strValue}</span>
161+
return (
162+
<span
163+
className='text-[var(--text-primary)] cursor-text'
164+
onDoubleClick={(e) => {
165+
e.stopPropagation()
166+
onDoubleClick()
167+
}}
168+
>
169+
{strValue}
170+
</span>
171+
)
99172
}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ export * from './cell-renderer'
44
export * from './cell-viewer-modal'
55
export * from './context-menu'
66
export * from './header-bar'
7+
export * from './inline-cell-editor'
78
export * from './pagination'
89
export * from './query-builder'
910
export * from './row-modal'
1011
export * from './schema-modal'
12+
export * from './table-row-cells'
1113
export * from './table-viewer'
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
'use client'
2+
3+
import { useEffect, useRef, useState } from 'react'
4+
import type { ColumnDefinition } from '@/lib/table'
5+
import { cleanCellValue, formatValueForInput } from '../lib/utils'
6+
7+
interface InlineCellEditorProps {
8+
value: unknown
9+
column: ColumnDefinition
10+
onSave: (value: unknown) => void
11+
onCancel: () => void
12+
}
13+
14+
export function InlineCellEditor({ value, column, onSave, onCancel }: InlineCellEditorProps) {
15+
const inputRef = useRef<HTMLInputElement>(null)
16+
const [draft, setDraft] = useState(() => formatValueForInput(value, column.type))
17+
const doneRef = useRef(false)
18+
19+
useEffect(() => {
20+
const input = inputRef.current
21+
if (input) {
22+
input.focus()
23+
input.select()
24+
}
25+
}, [])
26+
27+
const handleSave = () => {
28+
if (doneRef.current) return
29+
doneRef.current = true
30+
31+
try {
32+
const cleaned = cleanCellValue(draft, column)
33+
onSave(cleaned)
34+
} catch {
35+
onCancel()
36+
}
37+
}
38+
39+
const handleKeyDown = (e: React.KeyboardEvent) => {
40+
if (e.key === 'Enter') {
41+
e.preventDefault()
42+
handleSave()
43+
} else if (e.key === 'Escape') {
44+
e.preventDefault()
45+
doneRef.current = true
46+
onCancel()
47+
}
48+
}
49+
50+
const inputType = column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'
51+
52+
return (
53+
<input
54+
ref={inputRef}
55+
type={inputType}
56+
value={draft}
57+
onChange={(e) => setDraft(e.target.value)}
58+
onKeyDown={handleKeyDown}
59+
onBlur={handleSave}
60+
className='h-full w-full border-none bg-transparent text-[13px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--accent)] ring-inset rounded-[2px] px-[4px] py-[2px]'
61+
/>
62+
)
63+
}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal.tsx

Lines changed: 5 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
useDeleteTableRows,
2424
useUpdateTableRow,
2525
} from '@/hooks/queries/tables'
26+
import { cleanCellValue, formatValueForInput } from '../lib/utils'
2627

2728
const logger = createLogger('RowModal')
2829

@@ -56,48 +57,16 @@ function cleanRowData(
5657

5758
columns.forEach((col) => {
5859
const value = rowData[col.name]
59-
if (col.type === 'number') {
60-
cleanData[col.name] = value === '' ? null : Number(value)
61-
} else if (col.type === 'json') {
62-
if (typeof value === 'string') {
63-
if (value === '') {
64-
cleanData[col.name] = null
65-
} else {
66-
try {
67-
cleanData[col.name] = JSON.parse(value)
68-
} catch {
69-
throw new Error(`Invalid JSON for field: ${col.name}`)
70-
}
71-
}
72-
} else {
73-
cleanData[col.name] = value
74-
}
75-
} else if (col.type === 'boolean') {
76-
cleanData[col.name] = Boolean(value)
77-
} else {
78-
cleanData[col.name] = value || null
60+
try {
61+
cleanData[col.name] = cleanCellValue(value, col)
62+
} catch {
63+
throw new Error(`Invalid JSON for field: ${col.name}`)
7964
}
8065
})
8166

8267
return cleanData
8368
}
8469

85-
function formatValueForInput(value: unknown, type: string): string {
86-
if (value === null || value === undefined) return ''
87-
if (type === 'json') {
88-
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
89-
}
90-
if (type === 'date' && value) {
91-
try {
92-
const date = new Date(String(value))
93-
return date.toISOString().split('T')[0]
94-
} catch {
95-
return String(value)
96-
}
97-
}
98-
return String(value)
99-
}
100-
10170
function getInitialRowData(
10271
mode: RowModalProps['mode'],
10372
columns: ColumnDefinition[],
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React from 'react'
2+
import { Checkbox, TableCell, TableRow } from '@/components/emcn'
3+
import { cn } from '@/lib/core/utils/cn'
4+
import type { ColumnDefinition, TableRow as TableRowType } from '@/lib/table'
5+
import type { CellViewerData } from '../lib/types'
6+
import { CellRenderer } from './cell-renderer'
7+
8+
interface TableRowCellsProps {
9+
row: TableRowType
10+
columns: ColumnDefinition[]
11+
isSelected: boolean
12+
editingColumnName: string | null
13+
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
14+
onDoubleClick: (rowId: string, columnName: string) => void
15+
onSave: (rowId: string, columnName: string, value: unknown) => void
16+
onCancel: () => void
17+
onBooleanToggle: (rowId: string, columnName: string, currentValue: boolean) => void
18+
onContextMenu: (e: React.MouseEvent, row: TableRowType) => void
19+
onSelectRow: (rowId: string) => void
20+
}
21+
22+
export const TableRowCells = React.memo(function TableRowCells({
23+
row,
24+
columns,
25+
isSelected,
26+
editingColumnName,
27+
onCellClick,
28+
onDoubleClick,
29+
onSave,
30+
onCancel,
31+
onBooleanToggle,
32+
onContextMenu,
33+
onSelectRow,
34+
}: TableRowCellsProps) {
35+
return (
36+
<TableRow
37+
className={cn(
38+
'group hover:bg-[var(--surface-4)]',
39+
isSelected && 'bg-[var(--surface-5)]'
40+
)}
41+
onContextMenu={(e) => onContextMenu(e, row)}
42+
>
43+
<TableCell>
44+
<Checkbox
45+
size='sm'
46+
checked={isSelected}
47+
onCheckedChange={() => onSelectRow(row.id)}
48+
/>
49+
</TableCell>
50+
{columns.map((column) => (
51+
<TableCell key={column.name}>
52+
<div className='max-w-[300px] truncate text-[13px]'>
53+
<CellRenderer
54+
value={row.data[column.name]}
55+
column={column}
56+
isEditing={editingColumnName === column.name}
57+
onCellClick={onCellClick}
58+
onDoubleClick={() => onDoubleClick(row.id, column.name)}
59+
onSave={(value) => onSave(row.id, column.name, value)}
60+
onCancel={onCancel}
61+
onBooleanToggle={() =>
62+
onBooleanToggle(row.id, column.name, Boolean(row.data[column.name]))
63+
}
64+
/>
65+
</div>
66+
</TableCell>
67+
))}
68+
</TableRow>
69+
)
70+
})

0 commit comments

Comments
 (0)