From 8afa184c647c20fe6fcfe702719f5e463e93cf63 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 10 Mar 2026 01:55:54 -0700 Subject: [PATCH] feat: inline chunk editor and table batch ops with undo/redo (#3504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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` casts that lost the RowData type. Co-Authored-By: Claude Opus 4.6 * 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 * 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 --------- Co-authored-by: Claude Opus 4.6 --- .../sim/app/api/table/[tableId]/rows/route.ts | 116 +- .../[workspaceId]/components/error/error.tsx | 2 +- .../[workspaceId]/components/index.ts | 14 +- .../resource-options-bar.tsx | 12 +- .../components/resource/resource.tsx | 232 ++- .../chunk-context-menu/chunk-context-menu.tsx | 26 +- .../components/chunk-editor/chunk-editor.tsx | 180 ++ .../components/chunk-editor/index.ts | 1 + .../create-chunk-modal/create-chunk-modal.tsx | 168 -- .../edit-chunk-modal/edit-chunk-modal.tsx | 363 ---- .../[id]/[documentId]/components/index.ts | 3 +- .../knowledge/[id]/[documentId]/document.tsx | 1514 ++++++++--------- .../[workspaceId]/knowledge/[id]/base.tsx | 1502 ++++++---------- .../add-connector-modal.tsx | 63 +- .../add-documents-modal.tsx | 2 +- .../connectors-section/connectors-section.tsx | 17 +- .../document-context-menu.tsx | 23 +- .../knowledge-base-context-menu.tsx | 22 +- .../components/context-menu/context-menu.tsx | 131 +- .../[tableId]/components/table/table.tsx | 218 ++- .../table-context-menu/table-context-menu.tsx | 14 + .../workspace/[workspaceId]/tables/tables.tsx | 1 + .../nav-item-context-menu.tsx | 6 + .../w/components/sidebar/sidebar.tsx | 2 + apps/sim/hooks/queries/tables.ts | 115 +- apps/sim/hooks/use-table-undo.ts | 221 +++ apps/sim/lib/table/service.ts | 47 +- apps/sim/lib/table/types.ts | 2 + apps/sim/stores/table/store.ts | 137 ++ apps/sim/stores/table/types.ts | 53 + 30 files changed, 2700 insertions(+), 2507 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/edit-chunk-modal/edit-chunk-modal.tsx create mode 100644 apps/sim/hooks/use-table-undo.ts create mode 100644 apps/sim/stores/table/store.ts create mode 100644 apps/sim/stores/table/types.ts diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index bacfd840a6..5da39b2c05 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -9,6 +9,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import type { Filter, RowData, Sort, TableSchema } from '@/lib/table' import { batchInsertRows, + batchUpdateRows, deleteRowsByFilter, deleteRowsByIds, insertRow, @@ -30,13 +31,21 @@ const InsertRowSchema = z.object({ position: z.number().int().min(0).optional(), }) -const BatchInsertRowsSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - rows: z - .array(z.record(z.unknown()), { required_error: 'Rows array is required' }) - .min(1, 'At least one row is required') - .max(1000, 'Cannot insert more than 1000 rows per batch'), -}) +const BatchInsertRowsSchema = z + .object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + rows: z + .array(z.record(z.unknown()), { required_error: 'Rows array is required' }) + .min(1, 'At least one row is required') + .max(1000, 'Cannot insert more than 1000 rows per batch'), + positions: z.array(z.number().int().min(0)).max(1000).optional(), + }) + .refine((d) => !d.positions || d.positions.length === d.rows.length, { + message: 'positions array length must match rows array length', + }) + .refine((d) => !d.positions || new Set(d.positions).size === d.positions.length, { + message: 'positions must not contain duplicates', + }) const QueryRowsSchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), @@ -95,6 +104,22 @@ const DeleteRowsByIdsSchema = z.object({ const DeleteRowsRequestSchema = z.union([DeleteRowsByFilterSchema, DeleteRowsByIdsSchema]) +const BatchUpdateByIdsSchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + updates: z + .array( + z.object({ + rowId: z.string().min(1), + data: z.record(z.unknown()), + }) + ) + .min(1, 'At least one update is required') + .max(1000, 'Cannot update more than 1000 rows per batch') + .refine((d) => new Set(d.map((u) => u.rowId)).size === d.length, { + message: 'updates must not contain duplicate rowId values', + }), +}) + interface TableRowsRouteParams { params: Promise<{ tableId: string }> } @@ -135,6 +160,7 @@ async function handleBatchInsert( rows: validated.rows as RowData[], workspaceId: validated.workspaceId, userId, + positions: validated.positions, }, table, requestId @@ -600,3 +626,79 @@ export async function DELETE(request: NextRequest, { params }: TableRowsRoutePar return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 }) } } + +/** PATCH /api/table/[tableId]/rows - Batch updates rows by ID. */ +export async function PATCH(request: NextRequest, { params }: TableRowsRouteParams) { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) + } + + const validated = BatchUpdateByIdsSchema.parse(body) + + const accessResult = await checkAccess(tableId, authResult.userId, 'write') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + + const { table } = accessResult + + if (validated.workspaceId !== table.workspaceId) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const result = await batchUpdateRows( + { + tableId, + updates: validated.updates as Array<{ rowId: string; data: RowData }>, + workspaceId: validated.workspaceId, + }, + table, + requestId + ) + + return NextResponse.json({ + success: true, + data: { + message: 'Rows updated successfully', + updatedCount: result.affectedCount, + updatedRowIds: result.affectedRowIds, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : String(error) + + if ( + errorMessage.includes('Row size exceeds') || + errorMessage.includes('Schema validation') || + errorMessage.includes('must be unique') || + errorMessage.includes('Unique constraint violation') || + errorMessage.includes('Cannot set unique column') || + errorMessage.includes('Rows not found') + ) { + return NextResponse.json({ error: errorMessage }, { status: 400 }) + } + + logger.error(`[${requestId}] Error batch updating rows:`, error) + return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx b/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx index eba19c867f..524a7f8860 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx @@ -36,7 +36,7 @@ export function ErrorState({ }, [error, logger, loggerName]) return ( -
+

{title}

diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 3b9967fcba..e793f29232 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -9,8 +9,18 @@ export type { HeaderAction, } from './resource/components/resource-header' export { ResourceHeader } from './resource/components/resource-header' -export type { FilterTag, SearchConfig } from './resource/components/resource-options-bar' +export type { + FilterTag, + SearchConfig, + SortConfig, +} from './resource/components/resource-options-bar' export { ResourceOptionsBar } from './resource/components/resource-options-bar' export { timeCell } from './resource/components/time-cell/time-cell' -export type { ResourceCell, ResourceColumn, ResourceRow } from './resource/resource' +export type { + PaginationConfig, + ResourceCell, + ResourceColumn, + ResourceRow, + SelectableConfig, +} from './resource/resource' export { Resource } from './resource/resource' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx index 3d44484c50..c7e7399ac2 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx @@ -62,10 +62,17 @@ interface ResourceOptionsBarProps { sort?: SortConfig filter?: ReactNode filterTags?: FilterTag[] + extras?: ReactNode } -export function ResourceOptionsBar({ search, sort, filter, filterTags }: ResourceOptionsBarProps) { - const hasContent = search || sort || filter || (filterTags && filterTags.length > 0) +export function ResourceOptionsBar({ + search, + sort, + filter, + filterTags, + extras, +}: ResourceOptionsBarProps) { + const hasContent = search || sort || filter || extras || (filterTags && filterTags.length > 0) if (!hasContent) return null return ( @@ -127,6 +134,7 @@ export function ResourceOptionsBar({ search, sort, filter, filterTags }: Resourc
)}
+ {extras} {filterTags?.map((tag) => (