Skip to content

Commit 8afa184

Browse files
waleedlatif1claude
andauthored
feat: inline chunk editor and table batch ops with undo/redo (#3504)
* feat: inline chunk editor and table batch operations with undo/redo Replace modal-based chunk editing/creation with inline editor following the files tab pattern (state-based view toggle with ResourceHeader). Add batch update API endpoint, undo/redo support, and Popover-based context menus for tables. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove icons from table context menu PopoverItems Icons were incorrectly carried over from the DropdownMenu migration. PopoverItems in this codebase use text-only labels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: restore DropdownMenu for table context menu The table-level context menu was incorrectly migrated to Popover during conflict resolution. Only the row-level context menu uses Popover; the table context menu should remain DropdownMenu with icons, matching the base branch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: bound cross-page chunk navigation polling to max 50 retries Prevent indefinite polling if page data never loads during chunk navigation across page boundaries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: navigate to last page after chunk creation for multi-page documents After creating a chunk, navigate to the last page (where new chunks append) before selecting it. This prevents the editor from showing "Loading chunk..." when the new chunk is not on the current page. The loading state breadcrumb remains as an escape hatch for edge cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add duplicate rowId validation to BatchUpdateByIdsSchema Adds a .refine() check to reject duplicate rowIds in batch update requests, consistent with the positions uniqueness check on batch insert. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review comments - Fix disableEdit logic: use || instead of && so connector doc chunks cannot be edited from context menu (row click still opens viewer) - Add uniqueness validation for rowIds in BatchUpdateByIdsSchema - Fix inconsistent bg token: bg-background → bg-[var(--bg)] in Pagination Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove duplicate rowId uniqueness refine on BatchUpdateByIdsSchema The refine was applied both on the inner updates array and the outer object. Keep only the inner array refine which is cleaner. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address additional PR review comments - Fix stale rowId after create-row redo: patch undo stack with new row ID using patchUndoRowId so subsequent undo targets the correct row - Fix text color tokens in Pagination: use CSS variable references (text-[var(--text-body)], text-[var(--text-secondary)]) instead of Tailwind semantic tokens for consistency with the rest of the file Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove dead code and fix type errors in table context menu Remove unused `onAddData` prop and `isEmptyCell` variable from row context menu (introduced in PR but never wired to JSX). Fix type errors in optimistic update spreads by removing unnecessary `as Record<string, unknown>` casts that lost the RowData type. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent false "Saved" status on invalid content and mark fire-and-forget goToPage calls ChunkEditor.handleSave now throws on empty/oversized content instead of silently returning, so the parent's catch block correctly sets saveStatus to 'error'. Also added explicit `void` to unawaited goToPage(1) calls in filter handlers to signal intentional fire-and-forget. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: handle stale totalPages in handleChunkCreated for new-page edge case When creating a chunk that spills onto a new page, totalPages in the closure is stale. Now polls displayChunksRef for the new chunk, and if not found, checks totalPagesRef for an updated page count and navigates to the new last page before continuing to poll. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9b10e44 commit 8afa184

File tree

30 files changed

+2692
-2499
lines changed

30 files changed

+2692
-2499
lines changed

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

Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
99
import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
1010
import {
1111
batchInsertRows,
12+
batchUpdateRows,
1213
deleteRowsByFilter,
1314
deleteRowsByIds,
1415
insertRow,
@@ -30,13 +31,21 @@ const InsertRowSchema = z.object({
3031
position: z.number().int().min(0).optional(),
3132
})
3233

33-
const BatchInsertRowsSchema = z.object({
34-
workspaceId: z.string().min(1, 'Workspace ID is required'),
35-
rows: z
36-
.array(z.record(z.unknown()), { required_error: 'Rows array is required' })
37-
.min(1, 'At least one row is required')
38-
.max(1000, 'Cannot insert more than 1000 rows per batch'),
39-
})
34+
const BatchInsertRowsSchema = z
35+
.object({
36+
workspaceId: z.string().min(1, 'Workspace ID is required'),
37+
rows: z
38+
.array(z.record(z.unknown()), { required_error: 'Rows array is required' })
39+
.min(1, 'At least one row is required')
40+
.max(1000, 'Cannot insert more than 1000 rows per batch'),
41+
positions: z.array(z.number().int().min(0)).max(1000).optional(),
42+
})
43+
.refine((d) => !d.positions || d.positions.length === d.rows.length, {
44+
message: 'positions array length must match rows array length',
45+
})
46+
.refine((d) => !d.positions || new Set(d.positions).size === d.positions.length, {
47+
message: 'positions must not contain duplicates',
48+
})
4049

4150
const QueryRowsSchema = z.object({
4251
workspaceId: z.string().min(1, 'Workspace ID is required'),
@@ -95,6 +104,22 @@ const DeleteRowsByIdsSchema = z.object({
95104

96105
const DeleteRowsRequestSchema = z.union([DeleteRowsByFilterSchema, DeleteRowsByIdsSchema])
97106

107+
const BatchUpdateByIdsSchema = z.object({
108+
workspaceId: z.string().min(1, 'Workspace ID is required'),
109+
updates: z
110+
.array(
111+
z.object({
112+
rowId: z.string().min(1),
113+
data: z.record(z.unknown()),
114+
})
115+
)
116+
.min(1, 'At least one update is required')
117+
.max(1000, 'Cannot update more than 1000 rows per batch')
118+
.refine((d) => new Set(d.map((u) => u.rowId)).size === d.length, {
119+
message: 'updates must not contain duplicate rowId values',
120+
}),
121+
})
122+
98123
interface TableRowsRouteParams {
99124
params: Promise<{ tableId: string }>
100125
}
@@ -135,6 +160,7 @@ async function handleBatchInsert(
135160
rows: validated.rows as RowData[],
136161
workspaceId: validated.workspaceId,
137162
userId,
163+
positions: validated.positions,
138164
},
139165
table,
140166
requestId
@@ -600,3 +626,79 @@ export async function DELETE(request: NextRequest, { params }: TableRowsRoutePar
600626
return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 })
601627
}
602628
}
629+
630+
/** PATCH /api/table/[tableId]/rows - Batch updates rows by ID. */
631+
export async function PATCH(request: NextRequest, { params }: TableRowsRouteParams) {
632+
const requestId = generateRequestId()
633+
const { tableId } = await params
634+
635+
try {
636+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
637+
if (!authResult.success || !authResult.userId) {
638+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
639+
}
640+
641+
let body: unknown
642+
try {
643+
body = await request.json()
644+
} catch {
645+
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
646+
}
647+
648+
const validated = BatchUpdateByIdsSchema.parse(body)
649+
650+
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
651+
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
652+
653+
const { table } = accessResult
654+
655+
if (validated.workspaceId !== table.workspaceId) {
656+
logger.warn(
657+
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
658+
)
659+
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
660+
}
661+
662+
const result = await batchUpdateRows(
663+
{
664+
tableId,
665+
updates: validated.updates as Array<{ rowId: string; data: RowData }>,
666+
workspaceId: validated.workspaceId,
667+
},
668+
table,
669+
requestId
670+
)
671+
672+
return NextResponse.json({
673+
success: true,
674+
data: {
675+
message: 'Rows updated successfully',
676+
updatedCount: result.affectedCount,
677+
updatedRowIds: result.affectedRowIds,
678+
},
679+
})
680+
} catch (error) {
681+
if (error instanceof z.ZodError) {
682+
return NextResponse.json(
683+
{ error: 'Validation error', details: error.errors },
684+
{ status: 400 }
685+
)
686+
}
687+
688+
const errorMessage = error instanceof Error ? error.message : String(error)
689+
690+
if (
691+
errorMessage.includes('Row size exceeds') ||
692+
errorMessage.includes('Schema validation') ||
693+
errorMessage.includes('must be unique') ||
694+
errorMessage.includes('Unique constraint violation') ||
695+
errorMessage.includes('Cannot set unique column') ||
696+
errorMessage.includes('Rows not found')
697+
) {
698+
return NextResponse.json({ error: errorMessage }, { status: 400 })
699+
}
700+
701+
logger.error(`[${requestId}] Error batch updating rows:`, error)
702+
return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 })
703+
}
704+
}

apps/sim/app/workspace/[workspaceId]/components/error/error.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export function ErrorState({
3636
}, [error, logger, loggerName])
3737

3838
return (
39-
<div className='flex flex-1 items-center justify-center'>
39+
<div className='flex h-full flex-1 items-center justify-center'>
4040
<div className='flex flex-col items-center gap-[16px] text-center'>
4141
<div className='flex flex-col gap-[8px]'>
4242
<h2 className='font-semibold text-[16px] text-[var(--text-primary)]'>{title}</h2>

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,18 @@ export type {
99
HeaderAction,
1010
} from './resource/components/resource-header'
1111
export { ResourceHeader } from './resource/components/resource-header'
12-
export type { FilterTag, SearchConfig } from './resource/components/resource-options-bar'
12+
export type {
13+
FilterTag,
14+
SearchConfig,
15+
SortConfig,
16+
} from './resource/components/resource-options-bar'
1317
export { ResourceOptionsBar } from './resource/components/resource-options-bar'
1418
export { timeCell } from './resource/components/time-cell/time-cell'
15-
export type { ResourceCell, ResourceColumn, ResourceRow } from './resource/resource'
19+
export type {
20+
PaginationConfig,
21+
ResourceCell,
22+
ResourceColumn,
23+
ResourceRow,
24+
SelectableConfig,
25+
} from './resource/resource'
1626
export { Resource } from './resource/resource'

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,17 @@ interface ResourceOptionsBarProps {
6262
sort?: SortConfig
6363
filter?: ReactNode
6464
filterTags?: FilterTag[]
65+
extras?: ReactNode
6566
}
6667

67-
export function ResourceOptionsBar({ search, sort, filter, filterTags }: ResourceOptionsBarProps) {
68-
const hasContent = search || sort || filter || (filterTags && filterTags.length > 0)
68+
export function ResourceOptionsBar({
69+
search,
70+
sort,
71+
filter,
72+
filterTags,
73+
extras,
74+
}: ResourceOptionsBarProps) {
75+
const hasContent = search || sort || filter || extras || (filterTags && filterTags.length > 0)
6976
if (!hasContent) return null
7077

7178
return (
@@ -127,6 +134,7 @@ export function ResourceOptionsBar({ search, sort, filter, filterTags }: Resourc
127134
</div>
128135
)}
129136
<div className='flex items-center gap-[6px]'>
137+
{extras}
130138
{filterTags?.map((tag) => (
131139
<Button
132140
key={tag.label}

0 commit comments

Comments
 (0)