Skip to content

Commit a6d3b3a

Browse files
waleedlatif1claude
andauthored
improvement(tables): click-to-select navigation, inline rename, column resize (#3496)
* improvement(tables): click-to-select navigation, inline rename, column resize * fix(tables): address PR review comments - Add doneRef guard to useInlineRename preventing Enter+blur double-fire - Fix PATCH error handler: return 500 for non-validation errors, fix unreachable logger.error - Stop click propagation on breadcrumb rename input * fix(tables): add rows-affected check in renameTable service Prevents silent no-op when tableId doesn't match any record. * fix(tables): useMemo deps + placeholder memo initialCharacter check - Use primitive editingId/editValue in useMemo deps instead of whole useInlineRename object (which creates a new ref every render) - Add initialCharacter comparison to placeholderPropsAreEqual, matching the existing pattern in dataRowPropsAreEqual * fix(tables): address round 2 review comments - Mirror name validation (regex + max length) in PatchTableSchema so validateTableName failures return 400 instead of 500 - Add .returning() + rows-affected check to renameWorkspaceFile, matching the renameTable pattern - Check response.ok before parsing JSON in useRenameWorkspaceFile, matching the useRenameTable pattern * refactor(tables): reuse InlineRenameInput in BreadcrumbSegment Replace duplicated inline input markup with the shared component. Eliminates redundant useRef, useEffect, and input boilerplate. * fix(tables): set doneRef in cancelRename to prevent blur-triggered save Escape → cancelRename → input unmounts → blur → submitRename would save instead of canceling. Now cancelRename sets doneRef like submitRename does, blocking the subsequent blur handler. * fix(tables): pointercancel cleanup + typed FileConflictError - Add pointercancel handler to column resize to prevent listener leaks when system interrupts the pointer (touch-action override, etc.) - Replace stringly-typed error.message.includes('already exists') with FileConflictError class for refactor-safe 409 status detection * fix(tables): stable useCallback dep + rename shadowed variable - Use listRename.startRename (stable ref) instead of whole listRename object in handleContextMenuRename deps - Rename inner 'target' to 'origin' in arrow-key handler to avoid shadowing the outer HTMLElement 'target' * fix(tables): move class below imports, stable submitRename, clear editingCell - Move FileConflictError below import statements (import-first convention) - Make submitRename a stable useCallback([]) by reading editingId and editValue through refs (matches existing onSaveRef pattern) - Add setEditingCell(null) to handleEmptyRowClick for symmetry with handleCellClick * feat(tables): persist column widths in table metadata Column widths now survive navigation and page reloads. On resize-end, widths are debounced (500ms) and saved to the table's metadata field via a new PUT /api/table/[tableId]/metadata endpoint. On load, widths are seeded from the server once via React Query. * fix type checking for file viewer * fix(tables): address review feedback — 4 fixes 1. headerRename.onSave now uses the fileId parameter directly instead of the selectedFile closure, preventing rename-wrong-file race 2. updateMetadataMutation uses ref pattern matching mutateRef/createRef 3. Type-to-enter filters non-numeric chars for number columns, non-date chars for date columns 4. renameValue only passed to actively-renaming ColumnHeaderMenu, preserving React.memo for other columns * fix(tables): position-based gap rows, insert above/below, consistency fixes - Fix gap row insert shifting: only shift rows when target position is occupied, preventing unnecessary displacement of rows below - Switch to position-based indexing throughout (positionMap, maxPosition) instead of array-index for correct sparse position handling - Add insert row above/below to context menu - Use CellContent for pending values in PositionGapRows (matching PlaceholderRows) - Add belowHeader selection overlay logic to PositionGapRows - Remove unnecessary 500ms debounce on column width persistence Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix cells nav w keyboard * added preview panel for html, markdown rendering, completed table --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8fd8b1a commit a6d3b3a

File tree

23 files changed

+2052
-395
lines changed

23 files changed

+2052
-395
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { z } from 'zod'
4+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
5+
import { generateRequestId } from '@/lib/core/utils/request'
6+
import type { TableMetadata } from '@/lib/table'
7+
import { updateTableMetadata } from '@/lib/table'
8+
import { accessError, checkAccess } from '@/app/api/table/utils'
9+
10+
const logger = createLogger('TableMetadataAPI')
11+
12+
const MetadataSchema = z.object({
13+
workspaceId: z.string().min(1, 'Workspace ID is required'),
14+
metadata: z.object({
15+
columnWidths: z.record(z.number().positive()).optional(),
16+
}),
17+
})
18+
19+
interface TableRouteParams {
20+
params: Promise<{ tableId: string }>
21+
}
22+
23+
/** PUT /api/table/[tableId]/metadata - Update table UI metadata (column widths, etc.) */
24+
export async function PUT(request: NextRequest, { params }: TableRouteParams) {
25+
const requestId = generateRequestId()
26+
const { tableId } = await params
27+
28+
try {
29+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
30+
if (!authResult.success || !authResult.userId) {
31+
logger.warn(`[${requestId}] Unauthorized metadata update attempt`)
32+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
33+
}
34+
35+
const body = await request.json()
36+
const validated = MetadataSchema.parse(body)
37+
38+
const result = await checkAccess(tableId, authResult.userId, 'write')
39+
if (!result.ok) return accessError(result, requestId, tableId)
40+
41+
const { table } = result
42+
43+
if (table.workspaceId !== validated.workspaceId) {
44+
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
45+
}
46+
47+
const updated = await updateTableMetadata(
48+
tableId,
49+
validated.metadata,
50+
table.metadata as TableMetadata | null
51+
)
52+
53+
return NextResponse.json({ success: true, data: { metadata: updated } })
54+
} catch (error) {
55+
if (error instanceof z.ZodError) {
56+
return NextResponse.json(
57+
{ error: 'Validation error', details: error.errors },
58+
{ status: 400 }
59+
)
60+
}
61+
62+
logger.error(`[${requestId}] Error updating table metadata:`, error)
63+
return NextResponse.json({ error: 'Failed to update metadata' }, { status: 500 })
64+
}
65+
}

apps/sim/app/api/table/[tableId]/route.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { z } from 'zod'
44
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
55
import { generateRequestId } from '@/lib/core/utils/request'
6-
import { deleteTable, type TableSchema } from '@/lib/table'
6+
import { deleteTable, NAME_PATTERN, renameTable, TABLE_LIMITS, type TableSchema } from '@/lib/table'
77
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
88

99
const logger = createLogger('TableDetailAPI')
@@ -56,6 +56,7 @@ export async function GET(request: NextRequest, { params }: TableRouteParams) {
5656
schema: {
5757
columns: schemaData.columns.map(normalizeColumn),
5858
},
59+
metadata: table.metadata ?? null,
5960
rowCount: table.rowCount,
6061
maxRows: table.maxRows,
6162
createdAt:
@@ -82,6 +83,67 @@ export async function GET(request: NextRequest, { params }: TableRouteParams) {
8283
}
8384
}
8485

86+
const PatchTableSchema = z.object({
87+
workspaceId: z.string().min(1, 'Workspace ID is required'),
88+
name: z
89+
.string()
90+
.min(1, 'Name is required')
91+
.max(
92+
TABLE_LIMITS.MAX_TABLE_NAME_LENGTH,
93+
`Name must be at most ${TABLE_LIMITS.MAX_TABLE_NAME_LENGTH} characters`
94+
)
95+
.regex(
96+
NAME_PATTERN,
97+
'Name must start with letter or underscore, followed by alphanumeric or underscore'
98+
),
99+
})
100+
101+
/** PATCH /api/table/[tableId] - Renames a table. */
102+
export async function PATCH(request: NextRequest, { params }: TableRouteParams) {
103+
const requestId = generateRequestId()
104+
const { tableId } = await params
105+
106+
try {
107+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
108+
if (!authResult.success || !authResult.userId) {
109+
logger.warn(`[${requestId}] Unauthorized table rename attempt`)
110+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
111+
}
112+
113+
const body = await request.json()
114+
const validated = PatchTableSchema.parse(body)
115+
116+
const result = await checkAccess(tableId, authResult.userId, 'write')
117+
if (!result.ok) return accessError(result, requestId, tableId)
118+
119+
const { table } = result
120+
121+
if (table.workspaceId !== validated.workspaceId) {
122+
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
123+
}
124+
125+
const updated = await renameTable(tableId, validated.name, requestId)
126+
127+
return NextResponse.json({
128+
success: true,
129+
data: { table: updated },
130+
})
131+
} catch (error) {
132+
if (error instanceof z.ZodError) {
133+
return NextResponse.json(
134+
{ error: 'Validation error', details: error.errors },
135+
{ status: 400 }
136+
)
137+
}
138+
139+
logger.error(`[${requestId}] Error renaming table:`, error)
140+
return NextResponse.json(
141+
{ error: error instanceof Error ? error.message : 'Failed to rename table' },
142+
{ status: 500 }
143+
)
144+
}
145+
}
146+
85147
/** DELETE /api/table/[tableId] - Deletes a table and all its rows. */
86148
export async function DELETE(request: NextRequest, { params }: TableRouteParams) {
87149
const requestId = generateRequestId()

apps/sim/app/api/table/[tableId]/rows/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const logger = createLogger('TableRowsAPI')
2727
const InsertRowSchema = z.object({
2828
workspaceId: z.string().min(1, 'Workspace ID is required'),
2929
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
30+
position: z.number().int().min(0).optional(),
3031
})
3132

3233
const BatchInsertRowsSchema = z.object({
@@ -235,6 +236,7 @@ export async function POST(request: NextRequest, { params }: TableRowsRouteParam
235236
data: rowData,
236237
workspaceId: validated.workspaceId,
237238
userId: authResult.userId,
239+
position: validated.position,
238240
},
239241
table,
240242
requestId

apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,81 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
44
import { getSession } from '@/lib/auth'
55
import { generateRequestId } from '@/lib/core/utils/request'
6-
import { deleteWorkspaceFile } from '@/lib/uploads/contexts/workspace'
6+
import {
7+
deleteWorkspaceFile,
8+
FileConflictError,
9+
renameWorkspaceFile,
10+
} from '@/lib/uploads/contexts/workspace'
711
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
812

913
export const dynamic = 'force-dynamic'
1014

1115
const logger = createLogger('WorkspaceFileAPI')
1216

17+
/**
18+
* PATCH /api/workspaces/[id]/files/[fileId]
19+
* Rename a workspace file (requires write permission)
20+
*/
21+
export async function PATCH(
22+
request: NextRequest,
23+
{ params }: { params: Promise<{ id: string; fileId: string }> }
24+
) {
25+
const requestId = generateRequestId()
26+
const { id: workspaceId, fileId } = await params
27+
28+
try {
29+
const session = await getSession()
30+
if (!session?.user?.id) {
31+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
32+
}
33+
34+
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
35+
if (userPermission !== 'admin' && userPermission !== 'write') {
36+
logger.warn(
37+
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
38+
)
39+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
40+
}
41+
42+
const body = await request.json()
43+
const { name } = body
44+
45+
if (!name || typeof name !== 'string' || !name.trim()) {
46+
return NextResponse.json({ error: 'Name is required' }, { status: 400 })
47+
}
48+
49+
const updatedFile = await renameWorkspaceFile(workspaceId, fileId, name)
50+
51+
logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${updatedFile.name}"`)
52+
53+
recordAudit({
54+
workspaceId,
55+
actorId: session.user.id,
56+
actorName: session.user.name,
57+
actorEmail: session.user.email,
58+
action: AuditAction.FILE_UPDATED,
59+
resourceType: AuditResourceType.FILE,
60+
resourceId: fileId,
61+
description: `Renamed file to "${updatedFile.name}"`,
62+
request,
63+
})
64+
65+
return NextResponse.json({
66+
success: true,
67+
file: updatedFile,
68+
})
69+
} catch (error) {
70+
logger.error(`[${requestId}] Error renaming workspace file:`, error)
71+
return NextResponse.json(
72+
{
73+
success: false,
74+
error: error instanceof Error ? error.message : 'Failed to rename file',
75+
},
76+
{ status: error instanceof FileConflictError ? 409 : 500 }
77+
)
78+
}
79+
}
80+
1381
/**
1482
* DELETE /api/workspaces/[id]/files/[fileId]
1583
* Delete a workspace file (requires write permission)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export { ErrorState, type ErrorStateProps } from './error'
2+
export { InlineRenameInput } from './inline-rename-input'
23
export { ownerCell } from './resource/components/owner-cell/owner-cell'
34
export type {
5+
BreadcrumbEditing,
46
BreadcrumbItem,
57
CreateAction,
68
DropdownOption,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
'use client'
2+
3+
import { useEffect, useRef } from 'react'
4+
5+
interface InlineRenameInputProps {
6+
value: string
7+
onChange: (value: string) => void
8+
onSubmit: () => void
9+
onCancel: () => void
10+
}
11+
12+
export function InlineRenameInput({ value, onChange, onSubmit, onCancel }: InlineRenameInputProps) {
13+
const inputRef = useRef<HTMLInputElement>(null)
14+
15+
useEffect(() => {
16+
const el = inputRef.current
17+
if (el) {
18+
el.focus()
19+
el.select()
20+
}
21+
}, [])
22+
23+
return (
24+
<input
25+
ref={inputRef}
26+
type='text'
27+
value={value}
28+
onChange={(e) => onChange(e.target.value)}
29+
onKeyDown={(e) => {
30+
if (e.key === 'Enter') onSubmit()
31+
if (e.key === 'Escape') onCancel()
32+
}}
33+
onBlur={onSubmit}
34+
onClick={(e) => e.stopPropagation()}
35+
className='min-w-0 flex-1 truncate border-0 bg-transparent p-0 font-medium text-[14px] text-[var(--text-body)] outline-none focus:outline-none focus:ring-0'
36+
/>
37+
)
38+
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
1-
export type { BreadcrumbItem, CreateAction, DropdownOption, HeaderAction } from './resource-header'
1+
export type {
2+
BreadcrumbEditing,
3+
BreadcrumbItem,
4+
CreateAction,
5+
DropdownOption,
6+
HeaderAction,
7+
} from './resource-header'
28
export { ResourceHeader } from './resource-header'

apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
Plus,
1010
} from '@/components/emcn'
1111
import { cn } from '@/lib/core/utils/cn'
12+
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
1213

1314
export interface DropdownOption {
1415
label: string
@@ -17,10 +18,19 @@ export interface DropdownOption {
1718
disabled?: boolean
1819
}
1920

21+
export interface BreadcrumbEditing {
22+
isEditing: boolean
23+
value: string
24+
onChange: (value: string) => void
25+
onSubmit: () => void
26+
onCancel: () => void
27+
}
28+
2029
export interface BreadcrumbItem {
2130
label: string
2231
onClick?: () => void
2332
dropdownItems?: DropdownOption[]
33+
editing?: BreadcrumbEditing
2434
}
2535

2636
export interface HeaderAction {
@@ -73,6 +83,7 @@ export function ResourceHeader({
7383
label={crumb.label}
7484
onClick={crumb.onClick}
7585
dropdownItems={crumb.dropdownItems}
86+
editing={crumb.editing}
7687
/>
7788
</Fragment>
7889
))
@@ -125,12 +136,28 @@ function BreadcrumbSegment({
125136
label,
126137
onClick,
127138
dropdownItems,
139+
editing,
128140
}: {
129141
icon?: React.ElementType
130142
label: string
131143
onClick?: () => void
132144
dropdownItems?: DropdownOption[]
145+
editing?: BreadcrumbEditing
133146
}) {
147+
if (editing?.isEditing) {
148+
return (
149+
<span className='inline-flex items-center px-[8px] py-[4px]'>
150+
{Icon && <Icon className='mr-[12px] h-[14px] w-[14px] text-[var(--text-icon)]' />}
151+
<InlineRenameInput
152+
value={editing.value}
153+
onChange={editing.onChange}
154+
onSubmit={editing.onSubmit}
155+
onCancel={editing.onCancel}
156+
/>
157+
</span>
158+
)
159+
}
160+
134161
const content = (
135162
<>
136163
{Icon && <Icon className='mr-[12px] h-[14px] w-[14px] text-[var(--text-icon)]' />}

0 commit comments

Comments
 (0)