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>
This commit is contained in:
Waleed
2026-03-10 01:55:54 -07:00
committed by GitHub
parent 9b10e4464e
commit 8afa184c64
30 changed files with 2700 additions and 2507 deletions

View File

@@ -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 })
}
}

View File

@@ -36,7 +36,7 @@ export function ErrorState({
}, [error, logger, loggerName])
return (
<div className='flex flex-1 items-center justify-center'>
<div className='flex h-full flex-1 items-center justify-center'>
<div className='flex flex-col items-center gap-[16px] text-center'>
<div className='flex flex-col gap-[8px]'>
<h2 className='font-semibold text-[16px] text-[var(--text-primary)]'>{title}</h2>

View File

@@ -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'

View File

@@ -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
</div>
)}
<div className='flex items-center gap-[6px]'>
{extras}
{filterTags?.map((tag) => (
<Button
key={tag.label}

View File

@@ -2,9 +2,10 @@
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowDown, ArrowUp, Button, Loader, Plus, Skeleton } from '@/components/emcn'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { ArrowDown, ArrowUp, Button, Checkbox, Loader, Plus, Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { CreateAction, HeaderAction } from './components/resource-header'
import type { BreadcrumbItem, CreateAction, HeaderAction } from './components/resource-header'
import { ResourceHeader } from './components/resource-header'
import type { FilterTag, SearchConfig, SortConfig } from './components/resource-options-bar'
import { ResourceOptionsBar } from './components/resource-options-bar'
@@ -26,17 +27,34 @@ export interface ResourceRow {
sortValues?: Record<string, string | number>
}
export interface SelectableConfig {
selectedIds: Set<string>
onSelectRow: (id: string, checked: boolean) => void
onSelectAll: (checked: boolean) => void
isAllSelected: boolean
disabled?: boolean
}
export interface PaginationConfig {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
}
interface ResourceProps {
icon: React.ElementType
title: string
breadcrumbs?: BreadcrumbItem[]
create?: CreateAction
search?: SearchConfig
defaultSort?: string
disableHeaderSort?: boolean
sort?: SortConfig
headerActions?: HeaderAction[]
columns: ResourceColumn[]
rows: ResourceRow[]
selectedRowId?: string | null
selectable?: SelectableConfig
onRowClick?: (rowId: string) => void
onRowHover?: (rowId: string) => void
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
@@ -45,9 +63,11 @@ interface ResourceProps {
onContextMenu?: (e: React.MouseEvent) => void
filter?: ReactNode
filterTags?: FilterTag[]
extras?: ReactNode
onLoadMore?: () => void
hasMore?: boolean
isLoadingMore?: boolean
pagination?: PaginationConfig
emptyMessage?: string
contentOverride?: ReactNode
overlay?: ReactNode
@@ -62,14 +82,17 @@ const EMPTY_CELL_PLACEHOLDER = '- - -'
export function Resource({
icon,
title,
breadcrumbs,
create,
search,
defaultSort,
disableHeaderSort,
sort: sortOverride,
headerActions,
columns,
rows,
selectedRowId,
selectable,
onRowClick,
onRowHover,
onRowContextMenu,
@@ -78,9 +101,11 @@ export function Resource({
onContextMenu,
filter,
filterTags,
extras,
onLoadMore,
hasMore,
isLoadingMore,
pagination,
emptyMessage,
contentOverride,
overlay,
@@ -113,7 +138,7 @@ export function Resource({
}, [sortEnabled, columns, sort, handleSort])
const displayRows = useMemo(() => {
if (!sortEnabled) return rows
if (!sortEnabled || sortOverride) return rows
return [...rows].sort((a, b) => {
const col = sort.column
const aVal = a.sortValues?.[col] ?? a.cells[col]?.label ?? ''
@@ -124,7 +149,7 @@ export function Resource({
: String(aVal).localeCompare(String(bVal))
return sort.direction === 'asc' ? -cmp : cmp
})
}, [rows, sort, sortEnabled])
}, [rows, sort, sortEnabled, sortOverride])
useEffect(() => {
if (!onLoadMore || !hasMore) return
@@ -140,23 +165,33 @@ export function Resource({
return () => observer.disconnect()
}, [onLoadMore, hasMore])
const hasCheckbox = selectable != null
const totalColSpan = columns.length + (hasCheckbox ? 1 : 0)
return (
<div
className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'
onContextMenu={onContextMenu}
>
<ResourceHeader icon={icon} title={title} create={create} actions={headerActions} />
<ResourceHeader
icon={icon}
title={title}
breadcrumbs={breadcrumbs}
create={create}
actions={headerActions}
/>
<ResourceOptionsBar
search={search}
sort={sortConfig}
sort={sortOverride ?? sortConfig}
filter={filter}
filterTags={filterTags}
extras={extras}
/>
{contentOverride ? (
<div className='min-h-0 flex-1 overflow-auto'>{contentOverride}</div>
) : isLoading ? (
<DataTableSkeleton columns={columns} rowCount={loadingRows} />
<DataTableSkeleton columns={columns} rowCount={loadingRows} hasCheckbox={hasCheckbox} />
) : rows.length === 0 && emptyMessage ? (
<div className='flex min-h-0 flex-1 items-center justify-center'>
<span className='text-[13px] text-[var(--text-secondary)]'>{emptyMessage}</span>
@@ -165,9 +200,20 @@ export function Resource({
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
<div ref={headerRef} className='overflow-hidden'>
<table className='w-full table-fixed text-[13px]'>
<ResourceColGroup columns={columns} />
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
{hasCheckbox && (
<th className='h-10 w-[52px] py-[6px] pr-0 pl-[20px] text-left align-middle'>
<Checkbox
size='sm'
checked={selectable.isAllSelected}
onCheckedChange={(checked) => selectable.onSelectAll(checked as boolean)}
disabled={selectable.disabled}
aria-label='Select all'
/>
</th>
)}
{columns.map((col) => {
if (disableHeaderSort || !sortEnabled) {
return (
@@ -207,35 +253,52 @@ export function Resource({
</div>
<div className='min-h-0 flex-1 overflow-auto' onScroll={handleBodyScroll}>
<table className='w-full table-fixed text-[13px]'>
<ResourceColGroup columns={columns} />
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
<tbody>
{displayRows.map((row) => (
<tr
key={row.id}
data-resource-row
data-row-id={row.id}
className={cn(
'transition-colors hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
selectedRowId === row.id && 'bg-[var(--surface-3)]'
)}
onClick={() => onRowClick?.(row.id)}
onMouseEnter={onRowHover ? () => onRowHover(row.id) : undefined}
onContextMenu={(e) => onRowContextMenu?.(e, row.id)}
>
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-[24px] py-[10px] align-middle'>
<CellContent
cell={{ ...cell, label: cell?.label || EMPTY_CELL_PLACEHOLDER }}
primary={colIdx === 0}
{displayRows.map((row) => {
const isSelected = selectable?.selectedIds.has(row.id) ?? false
return (
<tr
key={row.id}
data-resource-row
data-row-id={row.id}
className={cn(
'transition-colors hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]'
)}
onClick={() => onRowClick?.(row.id)}
onMouseEnter={onRowHover ? () => onRowHover(row.id) : undefined}
onContextMenu={(e) => onRowContextMenu?.(e, row.id)}
>
{hasCheckbox && (
<td className='w-[52px] py-[10px] pr-0 pl-[20px] align-middle'>
<Checkbox
size='sm'
checked={isSelected}
onCheckedChange={(checked) =>
selectable.onSelectRow(row.id, checked as boolean)
}
disabled={selectable.disabled}
aria-label='Select row'
onClick={(e) => e.stopPropagation()}
/>
</td>
)
})}
</tr>
))}
)}
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-[24px] py-[10px] align-middle'>
<CellContent
cell={{ ...cell, label: cell?.label || EMPTY_CELL_PLACEHOLDER }}
primary={colIdx === 0}
/>
</td>
)
})}
</tr>
)
})}
{create && (
<tr
className={cn(
@@ -246,7 +309,7 @@ export function Resource({
)}
onClick={create.disabled ? undefined : create.onClick}
>
<td colSpan={columns.length} className='px-[24px] py-[10px] align-middle'>
<td colSpan={totalColSpan} className='px-[24px] py-[10px] align-middle'>
<span className='flex items-center gap-[12px] font-medium text-[14px] text-[var(--text-secondary)]'>
<Plus className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
{create.label}
@@ -265,12 +328,78 @@ export function Resource({
)}
</div>
{overlay}
{pagination && pagination.totalPages > 1 && (
<Pagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={pagination.onPageChange}
/>
)}
</div>
)}
</div>
)
}
function Pagination({
currentPage,
totalPages,
onPageChange,
}: {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
}) {
return (
<div className='flex items-center justify-center border-[var(--border)] border-t bg-[var(--bg)] px-4 py-[10px]'>
<div className='flex items-center gap-1'>
<Button
variant='ghost'
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
>
<ChevronLeft className='h-3.5 w-3.5' />
</Button>
<div className='mx-[12px] flex items-center gap-[16px]'>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let page: number
if (totalPages <= 5) {
page = i + 1
} else if (currentPage <= 3) {
page = i + 1
} else if (currentPage >= totalPages - 2) {
page = totalPages - 4 + i
} else {
page = currentPage - 2 + i
}
if (page < 1 || page > totalPages) return null
return (
<button
key={page}
type='button'
onClick={() => onPageChange(page)}
className={cn(
'font-medium text-sm transition-colors hover:text-[var(--text-body)]',
page === currentPage ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]'
)}
>
{page}
</button>
)
})}
</div>
<Button
variant='ghost'
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
>
<ChevronRight className='h-3.5 w-3.5' />
</Button>
</div>
</div>
)
}
function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean }) {
if (cell.content) return <>{cell.content}</>
return (
@@ -286,9 +415,16 @@ function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean
)
}
function ResourceColGroup({ columns }: { columns: ResourceColumn[] }) {
function ResourceColGroup({
columns,
hasCheckbox,
}: {
columns: ResourceColumn[]
hasCheckbox?: boolean
}) {
return (
<colgroup>
{hasCheckbox && <col className='w-[52px]' />}
{columns.map((col, colIdx) => (
<col key={col.id} className={colIdx === 0 ? 'min-w-[200px]' : 'w-[160px]'} />
))}
@@ -296,14 +432,27 @@ function ResourceColGroup({ columns }: { columns: ResourceColumn[] }) {
)
}
function DataTableSkeleton({ columns, rowCount }: { columns: ResourceColumn[]; rowCount: number }) {
function DataTableSkeleton({
columns,
rowCount,
hasCheckbox,
}: {
columns: ResourceColumn[]
rowCount: number
hasCheckbox?: boolean
}) {
return (
<>
<div className='overflow-hidden'>
<table className='w-full table-fixed text-[13px]'>
<ResourceColGroup columns={columns} />
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
{hasCheckbox && (
<th className='h-10 w-[52px] py-[10px] pr-0 pl-[20px] text-left align-middle'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
</th>
)}
{columns.map((col) => (
<th
key={col.id}
@@ -320,10 +469,15 @@ function DataTableSkeleton({ columns, rowCount }: { columns: ResourceColumn[]; r
</div>
<div className='min-h-0 flex-1 overflow-auto'>
<table className='w-full table-fixed text-[13px]'>
<ResourceColGroup columns={columns} />
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
<tbody>
{Array.from({ length: rowCount }, (_, i) => (
<tr key={i}>
{hasCheckbox && (
<td className='w-[52px] py-[10px] pr-0 pl-[20px] align-middle'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
</td>
)}
{columns.map((col, colIdx) => (
<td key={col.id} className='px-[24px] py-[10px] align-middle'>
<span className='flex min-h-[21px] items-center gap-[12px]'>

View File

@@ -45,6 +45,10 @@ interface ChunkContextMenuProps {
* Whether add chunk is disabled
*/
disableAddChunk?: boolean
/**
* Whether edit/view is disabled (e.g. user lacks permission)
*/
disableEdit?: boolean
/**
* Whether the document is synced from a connector (chunks are read-only)
*/
@@ -84,6 +88,7 @@ export function ChunkContextMenu({
disableToggleEnabled = false,
disableDelete = false,
disableAddChunk = false,
disableEdit = false,
isConnectorDocument = false,
selectedCount = 1,
enabledCount = 0,
@@ -99,6 +104,11 @@ export function ChunkContextMenu({
return isChunkEnabled ? 'Disable' : 'Enable'
}
const hasNavigationSection = !isMultiSelect && !!onOpenInNewTab
const hasEditSection = !isMultiSelect && (!!onEdit || !!onCopyContent)
const hasStateSection = !!onToggleEnabled
const hasDestructiveSection = !!onDelete
return (
<Popover
open={isOpen}
@@ -119,21 +129,23 @@ export function ChunkContextMenu({
{hasChunk ? (
<>
{/* Navigation */}
{!isMultiSelect && onOpenInNewTab && (
{hasNavigationSection && (
<PopoverItem
onClick={() => {
onOpenInNewTab()
onOpenInNewTab!()
onClose()
}}
>
Open in new tab
</PopoverItem>
)}
{!isMultiSelect && onOpenInNewTab && <PopoverDivider />}
{hasNavigationSection &&
(hasEditSection || hasStateSection || hasDestructiveSection) && <PopoverDivider />}
{/* Edit and copy actions */}
{!isMultiSelect && onEdit && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onEdit()
onClose()
@@ -152,7 +164,7 @@ export function ChunkContextMenu({
Copy content
</PopoverItem>
)}
{!isMultiSelect && (onEdit || onCopyContent) && <PopoverDivider />}
{hasEditSection && (hasStateSection || hasDestructiveSection) && <PopoverDivider />}
{/* State toggle */}
{onToggleEnabled && (
@@ -168,11 +180,7 @@ export function ChunkContextMenu({
)}
{/* Destructive action */}
{onDelete &&
((!isMultiSelect && onOpenInNewTab) ||
(!isMultiSelect && onEdit) ||
(!isMultiSelect && onCopyContent) ||
onToggleEnabled) && <PopoverDivider />}
{hasStateSection && hasDestructiveSection && <PopoverDivider />}
{onDelete && (
<PopoverItem
disabled={disableDelete}

View File

@@ -0,0 +1,180 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Label, Switch } from '@/components/emcn'
import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
import { useCreateChunk, useUpdateChunk } from '@/hooks/queries/kb/knowledge'
const TOKEN_BG_COLORS = [
'rgba(239, 68, 68, 0.55)',
'rgba(249, 115, 22, 0.55)',
'rgba(234, 179, 8, 0.55)',
'rgba(132, 204, 22, 0.55)',
'rgba(34, 197, 94, 0.55)',
'rgba(20, 184, 166, 0.55)',
'rgba(6, 182, 212, 0.55)',
'rgba(59, 130, 246, 0.55)',
'rgba(139, 92, 246, 0.55)',
'rgba(217, 70, 239, 0.55)',
] as const
interface ChunkEditorProps {
mode?: 'edit' | 'create'
chunk?: ChunkData
document: DocumentData
knowledgeBaseId: string
canEdit: boolean
maxChunkSize?: number
onDirtyChange: (isDirty: boolean) => void
saveRef: React.MutableRefObject<(() => Promise<void>) | null>
onCreated?: (chunkId: string) => void
}
export function ChunkEditor({
mode = 'edit',
chunk,
document: documentData,
knowledgeBaseId,
canEdit,
maxChunkSize,
onDirtyChange,
saveRef,
onCreated,
}: ChunkEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const { mutateAsync: updateChunk } = useUpdateChunk()
const { mutateAsync: createChunk } = useCreateChunk()
const isCreateMode = mode === 'create'
const chunkContent = chunk?.content ?? ''
const [editedContent, setEditedContent] = useState(isCreateMode ? '' : chunkContent)
const [tokenizerOn, setTokenizerOn] = useState(false)
const [hoveredTokenIndex, setHoveredTokenIndex] = useState<number | null>(null)
const editedContentRef = useRef(editedContent)
editedContentRef.current = editedContent
const isDirty = isCreateMode ? editedContent.trim().length > 0 : editedContent !== chunkContent
useEffect(() => {
if (!isCreateMode) {
setEditedContent(chunkContent)
}
}, [isCreateMode, chunk?.id, chunkContent])
useEffect(() => {
onDirtyChange(isDirty)
}, [isDirty, onDirtyChange])
useEffect(() => {
if (isCreateMode && textareaRef.current) {
textareaRef.current.focus()
}
}, [isCreateMode])
const handleSave = useCallback(async () => {
const content = editedContentRef.current
const trimmed = content.trim()
if (trimmed.length === 0) throw new Error('Content cannot be empty')
if (trimmed.length > 10000) throw new Error('Content exceeds maximum length')
if (isCreateMode) {
const created = await createChunk({
knowledgeBaseId,
documentId: documentData.id,
content: trimmed,
enabled: true,
})
onCreated?.(created.id)
} else {
if (!chunk || trimmed === chunk.content) return
await updateChunk({
knowledgeBaseId,
documentId: documentData.id,
chunkId: chunk.id,
content: trimmed,
})
}
}, [isCreateMode, chunk, knowledgeBaseId, documentData.id, updateChunk, createChunk, onCreated])
useEffect(() => {
if (saveRef) {
saveRef.current = handleSave
}
return () => {
if (saveRef) {
saveRef.current = null
}
}
}, [saveRef, handleSave])
const tokenStrings = useMemo(() => {
if (!tokenizerOn || !editedContent) return []
return getTokenStrings(editedContent)
}, [editedContent, tokenizerOn])
const tokenCount = useMemo(() => {
if (!editedContent) return 0
if (tokenizerOn) return tokenStrings.length
return getAccurateTokenCount(editedContent)
}, [editedContent, tokenizerOn, tokenStrings])
const isConnectorDocument = Boolean(documentData.connectorId)
return (
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='flex flex-1 overflow-hidden'>
{tokenizerOn ? (
<div className='h-full w-full overflow-y-auto whitespace-pre-wrap break-words p-[24px] font-sans text-[14px] text-[var(--text-body)]'>
{tokenStrings.map((token, index) => (
<span
key={index}
style={{ backgroundColor: TOKEN_BG_COLORS[index % TOKEN_BG_COLORS.length] }}
onMouseEnter={() => setHoveredTokenIndex(index)}
onMouseLeave={() => setHoveredTokenIndex(null)}
>
{token}
</span>
))}
</div>
) : (
<textarea
ref={textareaRef}
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
placeholder={
isCreateMode
? 'Enter the content for this chunk...'
: canEdit
? 'Enter chunk content...'
: isConnectorDocument
? 'This chunk is synced from a connector and cannot be edited'
: 'Read-only view'
}
className='h-full w-full resize-none border-0 bg-transparent p-[24px] font-sans text-[14px] text-[var(--text-body)] outline-none placeholder:text-[var(--text-subtle)]'
disabled={!canEdit}
readOnly={!canEdit}
spellCheck={false}
/>
)}
</div>
<div className='flex items-center justify-between border-[var(--border)] border-t px-[24px] py-[10px]'>
<div className='flex items-center gap-[8px]'>
<Label className='text-[12px] text-[var(--text-secondary)]'>Tokenizer</Label>
<Switch checked={tokenizerOn} onCheckedChange={setTokenizerOn} />
{tokenizerOn && hoveredTokenIndex !== null && (
<span className='text-[12px] text-[var(--text-tertiary)]'>
Token #{hoveredTokenIndex + 1}
</span>
)}
</div>
<span className='text-[12px] text-[var(--text-secondary)]'>
{tokenCount.toLocaleString()}
{maxChunkSize !== undefined && `/${maxChunkSize.toLocaleString()}`} tokens
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { ChunkEditor } from './chunk-editor'

View File

@@ -1,168 +0,0 @@
'use client'
import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import type { DocumentData } from '@/lib/knowledge/types'
import { useCreateChunk } from '@/hooks/queries/kb/knowledge'
const logger = createLogger('CreateChunkModal')
interface CreateChunkModalProps {
open: boolean
onOpenChange: (open: boolean) => void
document: DocumentData | null
knowledgeBaseId: string
}
export function CreateChunkModal({
open,
onOpenChange,
document,
knowledgeBaseId,
}: CreateChunkModalProps) {
const {
mutate: createChunk,
isPending: isCreating,
error: mutationError,
reset: resetMutation,
} = useCreateChunk()
const [content, setContent] = useState('')
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const isProcessingRef = useRef(false)
const error = mutationError?.message ?? null
const hasUnsavedChanges = content.trim().length > 0
const handleCreateChunk = () => {
if (!document || content.trim().length === 0 || isProcessingRef.current) {
if (isProcessingRef.current) {
logger.warn('Chunk creation already in progress, ignoring duplicate request')
}
return
}
isProcessingRef.current = true
createChunk(
{
knowledgeBaseId,
documentId: document.id,
content: content.trim(),
enabled: true,
},
{
onSuccess: () => {
isProcessingRef.current = false
onClose()
},
onError: () => {
isProcessingRef.current = false
},
}
)
}
const onClose = () => {
onOpenChange(false)
setContent('')
setShowUnsavedChangesAlert(false)
resetMutation()
}
const handleCloseAttempt = () => {
if (hasUnsavedChanges && !isCreating) {
setShowUnsavedChangesAlert(true)
} else {
onClose()
}
}
const handleConfirmDiscard = () => {
setShowUnsavedChangesAlert(false)
onClose()
}
const isFormValid = content.trim().length > 0 && content.trim().length <= 10000
return (
<>
<Modal open={open} onOpenChange={handleCloseAttempt}>
<ModalContent size='lg'>
<ModalHeader>Create Chunk</ModalHeader>
<form>
<ModalBody>
<div className='flex flex-col gap-[8px]'>
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}
{/* Content Input Section */}
<Label htmlFor='content'>Chunk</Label>
<Textarea
id='content'
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder='Enter the content for this chunk...'
rows={12}
disabled={isCreating}
/>
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={handleCloseAttempt}
type='button'
disabled={isCreating}
>
Cancel
</Button>
<Button
variant='tertiary'
onClick={handleCreateChunk}
type='button'
disabled={!isFormValid || isCreating}
>
{isCreating ? 'Creating...' : 'Create Chunk'}
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
{/* Unsaved Changes Alert */}
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<ModalContent size='sm'>
<ModalHeader>Discard Changes</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
You have unsaved changes. Are you sure you want to close without saving?
</p>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => setShowUnsavedChangesAlert(false)}
type='button'
>
Keep Editing
</Button>
<Button variant='destructive' onClick={handleConfirmDiscard} type='button'>
Discard Changes
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -1,363 +0,0 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ChevronDown, ChevronUp } from 'lucide-react'
import {
Button,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Switch,
Textarea,
Tooltip,
} from '@/components/emcn'
import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useUpdateChunk } from '@/hooks/queries/kb/knowledge'
const logger = createLogger('EditChunkModal')
interface EditChunkModalProps {
chunk: ChunkData | null
document: DocumentData | null
knowledgeBaseId: string
isOpen: boolean
onClose: () => void
allChunks?: ChunkData[]
currentPage?: number
totalPages?: number
onNavigateToChunk?: (chunk: ChunkData) => void
onNavigateToPage?: (page: number, selectChunk: 'first' | 'last') => Promise<void>
maxChunkSize?: number
}
export function EditChunkModal({
chunk,
document,
knowledgeBaseId,
isOpen,
onClose,
allChunks = [],
currentPage = 1,
totalPages = 1,
onNavigateToChunk,
onNavigateToPage,
maxChunkSize,
}: EditChunkModalProps) {
const userPermissions = useUserPermissionsContext()
const isConnectorDocument = Boolean(document?.connectorId)
const canEditChunk = userPermissions.canEdit && !isConnectorDocument
const {
mutate: updateChunk,
isPending: isSaving,
error: mutationError,
reset: resetMutation,
} = useUpdateChunk()
const [editedContent, setEditedContent] = useState(chunk?.content || '')
const [isNavigating, setIsNavigating] = useState(false)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
const [tokenizerOn, setTokenizerOn] = useState(false)
const [hoveredTokenIndex, setHoveredTokenIndex] = useState<number | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const error = mutationError?.message ?? null
const hasUnsavedChanges = editedContent !== (chunk?.content || '')
const tokenStrings = useMemo(() => {
if (!tokenizerOn || !editedContent) return []
return getTokenStrings(editedContent)
}, [editedContent, tokenizerOn])
const tokenCount = useMemo(() => {
if (!editedContent) return 0
if (tokenizerOn) return tokenStrings.length
return getAccurateTokenCount(editedContent)
}, [editedContent, tokenizerOn, tokenStrings])
const TOKEN_BG_COLORS = [
'rgba(239, 68, 68, 0.55)', // Red
'rgba(249, 115, 22, 0.55)', // Orange
'rgba(234, 179, 8, 0.55)', // Yellow
'rgba(132, 204, 22, 0.55)', // Lime
'rgba(34, 197, 94, 0.55)', // Green
'rgba(20, 184, 166, 0.55)', // Teal
'rgba(6, 182, 212, 0.55)', // Cyan
'rgba(59, 130, 246, 0.55)', // Blue
'rgba(139, 92, 246, 0.55)', // Violet
'rgba(217, 70, 239, 0.55)', // Fuchsia
]
const getTokenBgColor = (index: number): string => {
return TOKEN_BG_COLORS[index % TOKEN_BG_COLORS.length]
}
useEffect(() => {
if (chunk?.content) {
setEditedContent(chunk.content)
}
}, [chunk?.id, chunk?.content])
const currentChunkIndex = chunk ? allChunks.findIndex((c) => c.id === chunk.id) : -1
const canNavigatePrev = currentChunkIndex > 0 || currentPage > 1
const canNavigateNext = currentChunkIndex < allChunks.length - 1 || currentPage < totalPages
const handleSaveContent = () => {
if (!chunk || !document) return
updateChunk({
knowledgeBaseId,
documentId: document.id,
chunkId: chunk.id,
content: editedContent,
})
}
const navigateToChunk = async (direction: 'prev' | 'next') => {
if (!chunk || isNavigating) return
try {
setIsNavigating(true)
if (direction === 'prev') {
if (currentChunkIndex > 0) {
const prevChunk = allChunks[currentChunkIndex - 1]
onNavigateToChunk?.(prevChunk)
} else if (currentPage > 1) {
await onNavigateToPage?.(currentPage - 1, 'last')
}
} else {
if (currentChunkIndex < allChunks.length - 1) {
const nextChunk = allChunks[currentChunkIndex + 1]
onNavigateToChunk?.(nextChunk)
} else if (currentPage < totalPages) {
await onNavigateToPage?.(currentPage + 1, 'first')
}
}
} catch (err) {
logger.error(`Error navigating ${direction}:`, err)
} finally {
setIsNavigating(false)
}
}
const handleNavigate = (direction: 'prev' | 'next') => {
if (hasUnsavedChanges) {
setPendingNavigation(() => () => navigateToChunk(direction))
setShowUnsavedChangesAlert(true)
} else {
void navigateToChunk(direction)
}
}
const handleCloseAttempt = () => {
if (hasUnsavedChanges && !isSaving) {
setPendingNavigation(null)
setShowUnsavedChangesAlert(true)
} else {
resetMutation()
onClose()
}
}
const handleConfirmDiscard = () => {
setShowUnsavedChangesAlert(false)
if (pendingNavigation) {
void pendingNavigation()
setPendingNavigation(null)
} else {
resetMutation()
onClose()
}
}
const isFormValid = editedContent.trim().length > 0 && editedContent.trim().length <= 10000
if (!chunk || !document) return null
return (
<>
<Modal open={isOpen} onOpenChange={handleCloseAttempt}>
<ModalContent size='lg'>
<ModalHeader>
<div className='flex items-center gap-[8px]'>
<span>
{canEditChunk ? 'Edit' : 'View'} Chunk #{chunk.chunkIndex}
</span>
{/* Navigation Controls */}
<div className='flex items-center gap-[6px]'>
<Tooltip.Root>
<Tooltip.Trigger
asChild
onFocus={(e) => e.preventDefault()}
onBlur={(e) => e.preventDefault()}
>
<Button
variant='ghost'
onClick={() => handleNavigate('prev')}
disabled={!canNavigatePrev || isNavigating || isSaving}
className='h-[16px] w-[16px] p-0'
>
<ChevronUp className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
Previous chunk{' '}
{currentPage > 1 && currentChunkIndex === 0 ? '(previous page)' : ''}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger
asChild
onFocus={(e) => e.preventDefault()}
onBlur={(e) => e.preventDefault()}
>
<Button
variant='ghost'
onClick={() => handleNavigate('next')}
disabled={!canNavigateNext || isNavigating || isSaving}
className='h-[16px] w-[16px] p-0'
>
<ChevronDown className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
Next chunk{' '}
{currentPage < totalPages && currentChunkIndex === allChunks.length - 1
? '(next page)'
: ''}
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
</ModalHeader>
<form>
<ModalBody>
<div className='flex flex-col gap-[8px]'>
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}
{/* Content Input Section */}
<Label htmlFor='content'>Chunk</Label>
{tokenizerOn ? (
/* Tokenizer view - matches Textarea styling exactly (transparent border for spacing) */
<div
className='h-[418px] overflow-y-auto whitespace-pre-wrap break-words rounded-[4px] border border-transparent bg-[var(--surface-5)] px-[8px] py-[8px] font-medium font-sans text-[var(--text-primary)] text-sm'
style={{ minHeight: '418px' }}
>
{tokenStrings.map((token, index) => (
<span
key={index}
style={{
backgroundColor: getTokenBgColor(index),
}}
onMouseEnter={() => setHoveredTokenIndex(index)}
onMouseLeave={() => setHoveredTokenIndex(null)}
>
{token}
</span>
))}
</div>
) : (
/* Edit view - regular textarea */
<Textarea
ref={textareaRef}
id='content'
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
placeholder={
canEditChunk
? 'Enter chunk content...'
: isConnectorDocument
? 'This chunk is synced from a connector and cannot be edited'
: 'Read-only view'
}
rows={20}
disabled={isSaving || isNavigating || !canEditChunk}
readOnly={!canEditChunk}
/>
)}
</div>
{/* Tokenizer Section */}
<div className='flex items-center justify-between pt-[12px]'>
<div className='flex items-center gap-[8px]'>
<span className='text-[12px] text-[var(--text-secondary)]'>Tokenizer</span>
<Switch checked={tokenizerOn} onCheckedChange={setTokenizerOn} />
{tokenizerOn && hoveredTokenIndex !== null && (
<span className='text-[12px] text-[var(--text-tertiary)]'>
Token #{hoveredTokenIndex + 1}
</span>
)}
</div>
<span className='text-[12px] text-[var(--text-secondary)]'>
{tokenCount.toLocaleString()}
{maxChunkSize !== undefined && `/${maxChunkSize.toLocaleString()}`} tokens
</span>
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={handleCloseAttempt}
type='button'
disabled={isSaving || isNavigating}
>
Cancel
</Button>
{canEditChunk && (
<Button
variant='tertiary'
onClick={handleSaveContent}
type='button'
disabled={!isFormValid || isSaving || !hasUnsavedChanges || isNavigating}
>
{isSaving ? 'Saving...' : 'Save'}
</Button>
)}
</ModalFooter>
</form>
</ModalContent>
</Modal>
{/* Unsaved Changes Alert */}
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
You have unsaved changes to this chunk content.
{pendingNavigation
? ' Do you want to discard your changes and navigate to the next chunk?'
: ' Are you sure you want to discard your changes and close the editor?'}
</p>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => {
setShowUnsavedChangesAlert(false)
setPendingNavigation(null)
}}
type='button'
>
Keep Editing
</Button>
<Button variant='destructive' onClick={handleConfirmDiscard} type='button'>
Discard Changes
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -1,5 +1,4 @@
export { ChunkContextMenu } from './chunk-context-menu'
export { CreateChunkModal } from './create-chunk-modal/create-chunk-modal'
export { ChunkEditor } from './chunk-editor'
export { DeleteChunkModal } from './delete-chunk-modal/delete-chunk-modal'
export { DocumentTagsModal } from './document-tags-modal/document-tags-modal'
export { EditChunkModal } from './edit-chunk-modal/edit-chunk-modal'

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
'use client'
import { useMemo, useState } from 'react'
import { ArrowLeft, Loader2, Plus } from 'lucide-react'
import { ArrowLeft, Loader2, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -56,6 +56,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [apiKeyValue, setApiKeyValue] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const { workspaceId } = useParams<{ workspaceId: string }>()
const { mutate: createConnector, isPending: isCreating } = useCreateConnector()
@@ -85,6 +86,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
setApiKeyValue('')
setDisabledTagIds(new Set())
setError(null)
setSearchTerm('')
setStep('configure')
}
@@ -131,6 +133,15 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
const connectorEntries = Object.entries(CONNECTOR_REGISTRY)
const filteredEntries = useMemo(() => {
const term = searchTerm.toLowerCase().trim()
if (!term) return connectorEntries
return connectorEntries.filter(
([, config]) =>
config.name.toLowerCase().includes(term) || config.description.toLowerCase().includes(term)
)
}, [connectorEntries, searchTerm])
return (
<>
<Modal open={open} onOpenChange={(val) => !isCreating && onOpenChange(val)}>
@@ -151,16 +162,36 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
<ModalBody>
{step === 'select-type' ? (
<div className='flex flex-col gap-[8px]'>
{connectorEntries.map(([type, config]) => (
<ConnectorTypeCard
key={type}
config={config}
onClick={() => handleSelectType(type)}
<div className='flex items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
))}
{connectorEntries.length === 0 && (
<p className='text-[13px] text-[var(--text-muted)]'>No connectors available.</p>
)}
<Input
placeholder='Search sources...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='max-h-[400px] min-h-0 overflow-y-auto'>
<div className='flex flex-col gap-[2px]'>
{filteredEntries.map(([type, config]) => (
<ConnectorTypeCard
key={type}
config={config}
onClick={() => handleSelectType(type)}
/>
))}
{filteredEntries.length === 0 && (
<div className='py-[16px] text-center text-[14px] text-[var(--text-muted)]'>
{connectorEntries.length === 0
? 'No connectors available.'
: `No sources found matching "${searchTerm}"`}
</div>
)}
</div>
</div>
</div>
) : connectorConfig ? (
<div className='flex flex-col gap-[12px]'>
@@ -370,13 +401,15 @@ function ConnectorTypeCard({ config, onClick }: ConnectorTypeCardProps) {
return (
<button
type='button'
className='flex items-center gap-[12px] rounded-[8px] border border-[var(--border-1)] px-[14px] py-[12px] text-left transition-colors hover:bg-[var(--surface-2)]'
className='flex items-center gap-[10px] rounded-[6px] px-[10px] py-[8px] text-left transition-colors hover:bg-[var(--surface-3)]'
onClick={onClick}
>
<Icon className='h-6 w-6 flex-shrink-0' />
<div className='flex flex-col gap-[2px]'>
<span className='font-medium text-[14px] text-[var(--text-primary)]'>{config.name}</span>
<span className='text-[12px] text-[var(--text-muted)]'>{config.description}</span>
<Icon className='h-[18px] w-[18px] flex-shrink-0' />
<div className='flex min-w-0 flex-col gap-[1px]'>
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
{config.name}
</span>
<span className='truncate text-[11px] text-[var(--text-muted)]'>{config.description}</span>
</div>
</button>
)

View File

@@ -222,7 +222,7 @@ export function AddDocumentsModal({
return (
<Modal open={open} onOpenChange={handleClose}>
<ModalContent size='md'>
<ModalHeader>Add Documents</ModalHeader>
<ModalHeader>New Documents</ModalHeader>
<ModalBody>
<div className='min-h-0 flex-1 overflow-y-auto'>

View File

@@ -13,7 +13,6 @@ import {
RefreshCw,
Settings,
Trash,
Unplug,
XCircle,
} from 'lucide-react'
import {
@@ -54,7 +53,6 @@ interface ConnectorsSectionProps {
connectors: ConnectorData[]
isLoading: boolean
canEdit: boolean
onAddConnector: () => void
}
/** 5-minute cooldown after a manual sync trigger */
@@ -73,7 +71,6 @@ export function ConnectorsSection({
connectors,
isLoading,
canEdit,
onAddConnector,
}: ConnectorsSectionProps) {
const { mutate: triggerSync, isPending: isSyncing } = useTriggerSync()
const { mutate: updateConnector, isPending: isUpdating } = useUpdateConnector()
@@ -133,19 +130,7 @@ export function ConnectorsSection({
return (
<div className='mt-[16px]'>
<div className='flex items-center justify-between'>
<h2 className='font-medium text-[14px] text-[var(--text-secondary)]'>Connected Sources</h2>
{canEdit && (
<Button
variant='default'
className='h-[28px] rounded-[6px] text-[12px]'
onClick={onAddConnector}
>
<Unplug className='mr-1 h-3.5 w-3.5' />
Connect Source
</Button>
)}
</div>
<h2 className='font-medium text-[14px] text-[var(--text-secondary)]'>Connected Sources</h2>
{error && (
<p className='mt-[8px] text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>

View File

@@ -38,6 +38,10 @@ interface DocumentContextMenuProps {
* Whether the document has tags to view
*/
hasTags?: boolean
/**
* Whether rename is disabled
*/
disableRename?: boolean
/**
* Whether toggle enabled is disabled
*/
@@ -84,6 +88,7 @@ export function DocumentContextMenu({
isDocumentEnabled = true,
hasDocument,
hasTags = false,
disableRename = false,
disableToggleEnabled = false,
disableDelete = false,
disableAddDocument = false,
@@ -101,6 +106,11 @@ export function DocumentContextMenu({
return isDocumentEnabled ? 'Disable' : 'Enable'
}
const hasNavigationSection = !isMultiSelect && (!!onOpenInNewTab || !!onOpenSource)
const hasEditSection = !isMultiSelect && (!!onRename || (hasTags && !!onViewTags))
const hasStateSection = !!onToggleEnabled
const hasDestructiveSection = !!onDelete
return (
<Popover
open={isOpen}
@@ -141,11 +151,13 @@ export function DocumentContextMenu({
Open source
</PopoverItem>
)}
{!isMultiSelect && (onOpenInNewTab || onOpenSource) && <PopoverDivider />}
{hasNavigationSection &&
(hasEditSection || hasStateSection || hasDestructiveSection) && <PopoverDivider />}
{/* Edit and view actions */}
{!isMultiSelect && onRename && (
<PopoverItem
disabled={disableRename}
onClick={() => {
onRename()
onClose()
@@ -164,7 +176,7 @@ export function DocumentContextMenu({
View tags
</PopoverItem>
)}
{!isMultiSelect && (onRename || (hasTags && onViewTags)) && <PopoverDivider />}
{hasEditSection && (hasStateSection || hasDestructiveSection) && <PopoverDivider />}
{/* State toggle */}
{onToggleEnabled && (
@@ -180,12 +192,7 @@ export function DocumentContextMenu({
)}
{/* Destructive action */}
{onDelete &&
((!isMultiSelect && onOpenInNewTab) ||
(!isMultiSelect && onOpenSource) ||
(!isMultiSelect && onRename) ||
(!isMultiSelect && hasTags && onViewTags) ||
onToggleEnabled) && <PopoverDivider />}
{hasStateSection && hasDestructiveSection && <PopoverDivider />}
{onDelete && (
<PopoverItem
disabled={disableDelete}

View File

@@ -98,6 +98,11 @@ export function KnowledgeBaseContextMenu({
disableEdit = false,
disableDelete = false,
}: KnowledgeBaseContextMenuProps) {
const hasNavigationSection = showOpenInNewTab && !!onOpenInNewTab
const hasInfoSection = (showViewTags && !!onViewTags) || !!onCopyId
const hasEditSection = showEdit && !!onEdit
const hasDestructiveSection = showDelete && !!onDelete
return (
<Popover
open={isOpen}
@@ -116,17 +121,19 @@ export function KnowledgeBaseContextMenu({
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Navigation */}
{showOpenInNewTab && onOpenInNewTab && (
{hasNavigationSection && (
<PopoverItem
onClick={() => {
onOpenInNewTab()
onOpenInNewTab!()
onClose()
}}
>
Open in new tab
</PopoverItem>
)}
{showOpenInNewTab && onOpenInNewTab && <PopoverDivider />}
{hasNavigationSection && (hasInfoSection || hasEditSection || hasDestructiveSection) && (
<PopoverDivider />
)}
{/* View and copy actions */}
{showViewTags && onViewTags && (
@@ -149,7 +156,7 @@ export function KnowledgeBaseContextMenu({
Copy ID
</PopoverItem>
)}
{((showViewTags && onViewTags) || onCopyId) && <PopoverDivider />}
{hasInfoSection && (hasEditSection || hasDestructiveSection) && <PopoverDivider />}
{/* Edit action */}
{showEdit && onEdit && (
@@ -165,12 +172,7 @@ export function KnowledgeBaseContextMenu({
)}
{/* Destructive action */}
{showDelete &&
onDelete &&
((showOpenInNewTab && onOpenInNewTab) ||
(showViewTags && onViewTags) ||
onCopyId ||
(showEdit && onEdit)) && <PopoverDivider />}
{hasEditSection && hasDestructiveSection && <PopoverDivider />}
{showDelete && onDelete && (
<PopoverItem
disabled={disableDelete}

View File

@@ -1,86 +1,103 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import { ArrowDown, ArrowUp, Pencil, Plus, Trash } from '@/components/emcn/icons'
import { ArrowDown, ArrowUp, Pencil, Trash } from '@/components/emcn/icons'
import type { ContextMenuState } from '../../types'
interface ContextMenuProps {
contextMenu: ContextMenuState
onClose: () => void
onEditCell: () => void
onAddData: () => void
onDelete: () => void
onInsertAbove: () => void
onInsertBelow: () => void
selectedRowCount?: number
disableEdit?: boolean
disableInsert?: boolean
disableDelete?: boolean
}
export function ContextMenu({
contextMenu,
onClose,
onEditCell,
onAddData,
onDelete,
onInsertAbove,
onInsertBelow,
selectedRowCount = 1,
disableEdit = false,
disableInsert = false,
disableDelete = false,
}: ContextMenuProps) {
const isEmptyCell = !contextMenu.row
const deleteLabel = selectedRowCount > 1 ? `Delete ${selectedRowCount} rows` : 'Delete row'
return (
<DropdownMenu open={contextMenu.isOpen} onOpenChange={(open) => !open && onClose()}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${contextMenu.position.x}px`,
top: `${contextMenu.position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' side='bottom' sideOffset={4} className='min-w-[160px]'>
{isEmptyCell ? (
<DropdownMenuItem onSelect={onAddData}>
<Plus />
Add data
</DropdownMenuItem>
) : (
<>
{contextMenu.columnName && (
<DropdownMenuItem onSelect={onEditCell}>
<Pencil />
Edit cell
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={onInsertAbove}>
<ArrowUp />
Insert row above
</DropdownMenuItem>
<DropdownMenuItem onSelect={onInsertBelow}>
<ArrowDown />
Insert row below
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className='text-[var(--text-error)] focus:text-[var(--text-error)]'
onSelect={onDelete}
>
<Trash />
{deleteLabel}
</DropdownMenuItem>
</>
<Popover open={contextMenu.isOpen} onOpenChange={(open) => !open && onClose()}>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenu.position.x}px`,
top: `${contextMenu.position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent
align='start'
side='bottom'
sideOffset={4}
border
className='!min-w-[160px] !rounded-[8px] !bg-[var(--bg)] !p-[6px] shadow-sm'
>
{contextMenu.columnName && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onEditCell()
onClose()
}}
>
<Pencil />
Edit cell
</PopoverItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<PopoverItem
disabled={disableInsert}
onClick={() => {
onInsertAbove()
onClose()
}}
>
<ArrowUp />
Insert row above
</PopoverItem>
<PopoverItem
disabled={disableInsert}
onClick={() => {
onInsertBelow()
onClose()
}}
>
<ArrowDown />
Insert row below
</PopoverItem>
<PopoverDivider />
<PopoverItem
disabled={disableDelete}
onClick={() => {
onDelete()
onClose()
}}
className='text-[var(--text-error)] focus:text-[var(--text-error)]'
>
<Trash />
{deleteLabel}
</PopoverItem>
</PopoverContent>
</Popover>
)
}

View File

@@ -42,6 +42,7 @@ import type {
ColumnOption,
SortConfig,
} from '@/app/workspace/[workspaceId]/components/resource/components/resource-options-bar'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
useAddTableColumn,
useCreateTableRow,
@@ -53,6 +54,8 @@ import {
useUpdateTableRow,
} from '@/hooks/queries/tables'
import { useInlineRename } from '@/hooks/use-inline-rename'
import { extractCreatedRowId, useTableUndo } from '@/hooks/use-table-undo'
import type { DeletedRowSnapshot } from '@/stores/table/types'
import { useContextMenu, useTableData } from '../../hooks'
import type { EditingCell, QueryOptions, SaveReason } from '../../types'
import { cleanCellValue, formatValueForInput } from '../../utils'
@@ -166,7 +169,7 @@ export function Table({
sort: null,
})
const [editingRow, setEditingRow] = useState<TableRowType | null>(null)
const [deletingRows, setDeletingRows] = useState<string[]>([])
const [deletingRows, setDeletingRows] = useState<DeletedRowSnapshot[]>([])
const [editingCell, setEditingCell] = useState<EditingCell | null>(null)
const [initialCharacter, setInitialCharacter] = useState<string | null>(null)
const [selectionAnchor, setSelectionAnchor] = useState<CellCoord | null>(null)
@@ -196,6 +199,7 @@ export function Table({
queryOptions,
})
const userPermissions = useUserPermissionsContext()
const {
contextMenu,
handleRowContextMenu: baseHandleRowContextMenu,
@@ -210,6 +214,14 @@ export function Table({
const deleteColumnMutation = useDeleteColumn({ workspaceId, tableId })
const updateMetadataMutation = useUpdateTableMetadata({ workspaceId, tableId })
const { pushUndo, undo, redo } = useTableUndo({ workspaceId, tableId })
const undoRef = useRef(undo)
undoRef.current = undo
const redoRef = useRef(redo)
redoRef.current = redo
const pushUndoRef = useRef(pushUndo)
pushUndoRef.current = pushUndo
const columns = useMemo(
() => tableData?.schema?.columns || EMPTY_COLUMNS,
[tableData?.schema?.columns]
@@ -292,11 +304,26 @@ export function Table({
const renameTableMutation = useRenameTable(workspaceId)
const tableHeaderRename = useInlineRename({
onSave: (_id, name) => renameTableMutation.mutate({ tableId, name }),
onSave: (_id, name) => {
if (tableData) {
pushUndoRef.current({
type: 'rename-table',
tableId,
previousName: tableData.name,
newName: name,
})
}
renameTableMutation.mutate({ tableId, name })
},
})
const columnRename = useInlineRename({
onSave: (columnName, newName) => {
pushUndoRef.current({ type: 'rename-column', oldName: columnName, newName })
setColumnWidths((prev) => {
if (!(columnName in prev)) return prev
return { ...prev, [newName]: prev[columnName] }
})
updateColumnMutation.mutate({ columnName, updates: { name: newName } })
},
})
@@ -315,14 +342,30 @@ export function Table({
}
}, [deleteTableMutation, tableId, router, workspaceId])
const toggleBooleanCell = useCallback(
(rowId: string, columnName: string, currentValue: unknown) => {
const newValue = !currentValue
pushUndoRef.current({
type: 'update-cell',
rowId,
columnName,
previousValue: currentValue ?? null,
newValue,
})
mutateRef.current({ rowId, data: { [columnName]: newValue } })
},
[]
)
const handleContextMenuEditCell = useCallback(() => {
if (contextMenu.row && contextMenu.columnName) {
const column = columnsRef.current.find((c) => c.name === contextMenu.columnName)
if (column?.type === 'boolean') {
mutateRef.current({
rowId: contextMenu.row.id,
data: { [contextMenu.columnName]: !contextMenu.row.data[contextMenu.columnName] },
})
toggleBooleanCell(
contextMenu.row.id,
contextMenu.columnName,
contextMenu.row.data[contextMenu.columnName]
)
} else if (column) {
setEditingCell({ rowId: contextMenu.row.id, columnName: contextMenu.columnName })
setInitialCharacter(null)
@@ -345,52 +388,51 @@ export function Table({
if (isInSelection && sel) {
const pMap = positionMapRef.current
const rowIds: string[] = []
const snapshots: DeletedRowSnapshot[] = []
for (let r = sel.startRow; r <= sel.endRow; r++) {
const row = pMap.get(r)
if (row) rowIds.push(row.id)
if (row) {
snapshots.push({ rowId: row.id, data: { ...row.data }, position: row.position })
}
}
if (rowIds.length > 0) {
setDeletingRows(rowIds)
if (snapshots.length > 0) {
setDeletingRows(snapshots)
}
} else {
setDeletingRows([contextMenu.row.id])
setDeletingRows([
{
rowId: contextMenu.row.id,
data: { ...contextMenu.row.data },
position: contextMenu.row.position,
},
])
}
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
const handleInsertRowAbove = useCallback(() => {
if (!contextMenu.row) return
createRef.current({ data: {}, position: contextMenu.row.position })
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
const handleInsertRowBelow = useCallback(() => {
if (!contextMenu.row) return
createRef.current({ data: {}, position: contextMenu.row.position + 1 })
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
const handleAddData = useCallback(() => {
if (contextMenu.rowIndex === null || !contextMenu.columnName) {
const handleInsertRow = useCallback(
(offset: 0 | 1) => {
if (!contextMenu.row) return
const position = contextMenu.row.position + offset
createRef.current(
{ data: {}, position },
{
onSuccess: (response: Record<string, unknown>) => {
const newRowId = extractCreatedRowId(response)
if (newRowId) {
pushUndoRef.current({ type: 'create-row', rowId: newRowId, position })
}
},
}
)
closeContextMenu()
return
}
const column = columnsRef.current.find((c) => c.name === contextMenu.columnName)
if (!column || column.type === 'boolean') {
closeContextMenu()
return
}
setSelectionAnchor({
rowIndex: contextMenu.rowIndex,
colIndex: columnsRef.current.findIndex((c) => c.name === contextMenu.columnName),
})
setSelectionFocus(null)
setEditingEmptyCell({ rowIndex: contextMenu.rowIndex, columnName: contextMenu.columnName })
setInitialCharacter(null)
closeContextMenu()
}, [contextMenu.rowIndex, contextMenu.columnName, closeContextMenu])
},
[contextMenu.row, closeContextMenu]
)
const handleInsertRowAbove = useCallback(() => handleInsertRow(0), [handleInsertRow])
const handleInsertRowBelow = useCallback(() => handleInsertRow(1), [handleInsertRow])
const resolveColumnFromEvent = useCallback((e: React.MouseEvent) => {
const td = (e.target as HTMLElement).closest('td[data-col]') as HTMLElement | null
@@ -531,7 +573,7 @@ export function Table({
if (column?.type === 'boolean') {
const row = rowsRef.current.find((r) => r.id === rowId)
if (row) {
mutateRef.current({ rowId, data: { [columnName]: !row.data[columnName] } })
toggleBooleanCell(rowId, columnName, row.data[columnName])
}
return
}
@@ -561,6 +603,9 @@ export function Table({
const updateMetadataRef = useRef(updateMetadataMutation.mutate)
updateMetadataRef.current = updateMetadataMutation.mutate
const toggleBooleanCellRef = useRef(toggleBooleanCell)
toggleBooleanCellRef.current = toggleBooleanCell
const editingCellRef = useRef(editingCell)
editingCellRef.current = editingCell
@@ -575,6 +620,16 @@ export function Table({
const tag = (e.target as HTMLElement).tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
e.preventDefault()
if (e.shiftKey) {
redoRef.current()
} else {
undoRef.current()
}
return
}
const anchor = selectionAnchorRef.current
if (!anchor || editingCellRef.current || editingEmptyCellRef.current) return
@@ -603,7 +658,7 @@ export function Table({
}
if (col.type === 'boolean') {
mutateRef.current({ rowId: row.id, data: { [col.name]: !row.data[col.name] } })
toggleBooleanCellRef.current(row.id, col.name, row.data[col.name])
return
}
setEditingCell({ rowId: row.id, columnName: col.name })
@@ -664,15 +719,25 @@ export function Table({
if (!sel) return
const pMap = positionMapRef.current
const undoCells: Array<{ rowId: string; data: Record<string, unknown> }> = []
for (let r = sel.startRow; r <= sel.endRow; r++) {
const row = pMap.get(r)
if (!row) continue
const updates: Record<string, unknown> = {}
const previousData: Record<string, unknown> = {}
for (let c = sel.startCol; c <= sel.endCol; c++) {
if (c < cols.length) updates[cols[c].name] = null
if (c < cols.length) {
const colName = cols[c].name
previousData[colName] = row.data[colName] ?? null
updates[colName] = null
}
}
undoCells.push({ rowId: row.id, data: previousData })
mutateRef.current({ rowId: row.id, data: updates })
}
if (undoCells.length > 0) {
pushUndoRef.current({ type: 'clear-cells', cells: undoCells })
}
return
}
@@ -810,6 +875,13 @@ export function Table({
const oldValue = row.data[columnName]
if (!(oldValue === value) && !(oldValue === null && value === null)) {
pushUndoRef.current({
type: 'update-cell',
rowId,
columnName,
previousValue: oldValue ?? null,
newValue: value,
})
mutateRef.current({ rowId, data: { [columnName]: value } })
}
@@ -916,10 +988,28 @@ export function Table({
}, [])
const handleAddColumn = useCallback(() => {
addColumnMutation.mutate({ name: generateColumnName(), type: 'string' })
const name = generateColumnName()
const position = columnsRef.current.length
addColumnMutation.mutate(
{ name, type: 'string' },
{
onSuccess: () => {
pushUndoRef.current({ type: 'create-column', columnName: name, position })
},
}
)
}, [generateColumnName])
const handleChangeType = useCallback((columnName: string, newType: string) => {
const column = columnsRef.current.find((c) => c.name === columnName)
if (column) {
pushUndoRef.current({
type: 'update-column-type',
columnName,
previousType: column.type,
newType,
})
}
updateColumnMutation.mutate({ columnName, updates: { type: newType } })
}, [])
@@ -927,7 +1017,15 @@ export function Table({
(columnName: string) => {
const index = columnsRef.current.findIndex((c) => c.name === columnName)
if (index === -1) return
addColumnMutation.mutate({ name: generateColumnName(), type: 'string', position: index })
const name = generateColumnName()
addColumnMutation.mutate(
{ name, type: 'string', position: index },
{
onSuccess: () => {
pushUndoRef.current({ type: 'create-column', columnName: name, position: index })
},
}
)
},
[generateColumnName]
)
@@ -936,7 +1034,16 @@ export function Table({
(columnName: string) => {
const index = columnsRef.current.findIndex((c) => c.name === columnName)
if (index === -1) return
addColumnMutation.mutate({ name: generateColumnName(), type: 'string', position: index + 1 })
const name = generateColumnName()
const position = index + 1
addColumnMutation.mutate(
{ name, type: 'string', position },
{
onSuccess: () => {
pushUndoRef.current({ type: 'create-column', columnName: name, position })
},
}
)
},
[generateColumnName]
)
@@ -944,7 +1051,15 @@ export function Table({
const handleToggleUnique = useCallback((columnName: string) => {
const column = columnsRef.current.find((c) => c.name === columnName)
if (!column) return
updateColumnMutation.mutate({ columnName, updates: { unique: !column.unique } })
const previousValue = !!column.unique
pushUndoRef.current({
type: 'toggle-column-constraint',
columnName,
constraint: 'unique',
previousValue,
newValue: !previousValue,
})
updateColumnMutation.mutate({ columnName, updates: { unique: !previousValue } })
}, [])
const handleDeleteColumn = useCallback((columnName: string) => {
@@ -1281,8 +1396,9 @@ export function Table({
isOpen={true}
onClose={() => setDeletingRows([])}
table={tableData}
rowIds={deletingRows}
rowIds={deletingRows.map((r) => r.rowId)}
onSuccess={() => {
pushUndo({ type: 'delete-rows', rows: deletingRows })
setDeletingRows([])
handleClearSelection()
}}
@@ -1293,11 +1409,13 @@ export function Table({
contextMenu={contextMenu}
onClose={closeContextMenu}
onEditCell={handleContextMenuEditCell}
onAddData={handleAddData}
onDelete={handleContextMenuDelete}
onInsertAbove={handleInsertRowAbove}
onInsertBelow={handleInsertRowBelow}
selectedRowCount={selectedRowCount}
disableEdit={!userPermissions.canEdit}
disableInsert={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit}
/>
{!embedded && (

View File

@@ -15,7 +15,11 @@ interface TableContextMenuProps {
onClose: () => void
onCopyId?: () => void
onDelete?: () => void
onViewSchema?: () => void
onRename?: () => void
disableDelete?: boolean
disableRename?: boolean
menuRef?: React.RefObject<HTMLDivElement | null>
}
export function TableContextMenu({
@@ -24,7 +28,10 @@ export function TableContextMenu({
onClose,
onCopyId,
onDelete,
onViewSchema,
onRename,
disableDelete = false,
disableRename = false,
}: TableContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()}>
@@ -43,6 +50,13 @@ export function TableContextMenu({
/>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' side='bottom' sideOffset={4}>
{onViewSchema && <DropdownMenuItem onSelect={onViewSchema}>View Schema</DropdownMenuItem>}
{onRename && (
<DropdownMenuItem disabled={disableRename} onSelect={onRename}>
Rename
</DropdownMenuItem>
)}
{(onViewSchema || onRename) && (onCopyId || onDelete) && <DropdownMenuSeparator />}
{onCopyId && (
<DropdownMenuItem onSelect={onCopyId}>
<Copy />

View File

@@ -198,6 +198,7 @@ export function Tables() {
}}
onDelete={() => setIsDeleteDialogOpen(true)}
disableDelete={userPermissions.canEdit !== true}
disableRename={userPermissions.canEdit !== true}
/>
<Modal open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>

View File

@@ -11,6 +11,8 @@ interface NavItemContextMenuProps {
onCopyLink: () => void
onRename?: () => void
onDelete?: () => void
disableRename?: boolean
disableDelete?: boolean
}
export function NavItemContextMenu({
@@ -22,6 +24,8 @@ export function NavItemContextMenu({
onCopyLink,
onRename,
onDelete,
disableRename = false,
disableDelete = false,
}: NavItemContextMenuProps) {
return (
<Popover
@@ -63,6 +67,7 @@ export function NavItemContextMenu({
onRename()
onClose()
}}
disabled={disableRename}
>
Rename
</PopoverItem>
@@ -73,6 +78,7 @@ export function NavItemContextMenu({
onDelete()
onClose()
}}
disabled={disableDelete}
className='text-[var(--color-error)]'
>
Delete

View File

@@ -981,6 +981,8 @@ export const Sidebar = memo(function Sidebar() {
activeTaskId && activeTaskId !== 'new' ? handleStartTaskRename : undefined
}
onDelete={activeTaskId ? handleDeleteTask : undefined}
disableRename={!canEdit}
disableDelete={!canEdit}
/>
</>
)}

View File

@@ -375,6 +375,53 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext)
})
}
interface BatchCreateTableRowsParams {
rows: Array<Record<string, unknown>>
positions?: number[]
}
interface BatchCreateTableRowsResponse {
success: boolean
data?: {
rows: TableRow[]
insertedCount: number
message: string
}
}
/**
* Batch create rows in a table. Supports optional per-row positions for undo restore.
*/
export function useBatchCreateTableRows({ workspaceId, tableId }: RowMutationContext) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (
variables: BatchCreateTableRowsParams
): Promise<BatchCreateTableRowsResponse> => {
const res = await fetch(`/api/table/${tableId}/rows`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspaceId,
rows: variables.rows,
positions: variables.positions,
}),
})
if (!res.ok) {
const error = await res.json().catch(() => ({}))
throw new Error(error.error || 'Failed to create rows')
}
return res.json()
},
onSettled: () => {
invalidateRowCount(queryClient, workspaceId, tableId)
},
})
}
/**
* Update a single row in a table.
* Uses optimistic updates for instant UI feedback on inline cell edits.
@@ -411,9 +458,7 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext)
return {
...old,
rows: old.rows.map((row) =>
row.id === rowId
? { ...row, data: { ...(row.data as Record<string, unknown>), ...data } }
: row
row.id === rowId ? { ...row, data: { ...row.data, ...data } } : row
),
}
}
@@ -434,6 +479,70 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext)
})
}
interface BatchUpdateTableRowsParams {
updates: Array<{ rowId: string; data: Record<string, unknown> }>
}
/**
* Batch update multiple rows by ID. Uses optimistic updates for instant UI feedback.
*/
export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationContext) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ updates }: BatchUpdateTableRowsParams) => {
const res = await fetch(`/api/table/${tableId}/rows`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId, updates }),
})
if (!res.ok) {
const error = await res.json().catch(() => ({}))
throw new Error(error.error || 'Failed to update rows')
}
return res.json()
},
onMutate: ({ updates }) => {
void queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) })
const previousQueries = queryClient.getQueriesData<TableRowsResponse>({
queryKey: tableKeys.rowsRoot(tableId),
})
const updateMap = new Map(updates.map((u) => [u.rowId, u.data]))
queryClient.setQueriesData<TableRowsResponse>(
{ queryKey: tableKeys.rowsRoot(tableId) },
(old) => {
if (!old) return old
return {
...old,
rows: old.rows.map((row) => {
const patch = updateMap.get(row.id)
if (!patch) return row
return { ...row, data: { ...row.data, ...patch } }
}),
}
}
)
return { previousQueries }
},
onError: (_err, _vars, context) => {
if (context?.previousQueries) {
for (const [queryKey, data] of context.previousQueries) {
queryClient.setQueryData(queryKey, data)
}
}
},
onSettled: () => {
invalidateRowData(queryClient, tableId)
},
})
}
/**
* Delete a single row from a table.
*/

View File

@@ -0,0 +1,221 @@
/**
* Hook that connects the table undo/redo store to React Query mutations.
*/
import { useCallback, useEffect } from 'react'
import { createLogger } from '@sim/logger'
import {
useAddTableColumn,
useBatchCreateTableRows,
useBatchUpdateTableRows,
useCreateTableRow,
useDeleteColumn,
useDeleteTableRow,
useDeleteTableRows,
useRenameTable,
useUpdateColumn,
useUpdateTableRow,
} from '@/hooks/queries/tables'
import { runWithoutRecording, useTableUndoStore } from '@/stores/table/store'
import type { TableUndoAction } from '@/stores/table/types'
const logger = createLogger('useTableUndo')
/**
* Extract the row ID from a create-row API response.
*/
export function extractCreatedRowId(response: Record<string, unknown>): string | undefined {
const data = response?.data as Record<string, unknown> | undefined
const row = data?.row as Record<string, unknown> | undefined
return row?.id as string | undefined
}
interface UseTableUndoProps {
workspaceId: string
tableId: string
}
export function useTableUndo({ workspaceId, tableId }: UseTableUndoProps) {
const push = useTableUndoStore((s) => s.push)
const popUndo = useTableUndoStore((s) => s.popUndo)
const popRedo = useTableUndoStore((s) => s.popRedo)
const patchRedoRowId = useTableUndoStore((s) => s.patchRedoRowId)
const patchUndoRowId = useTableUndoStore((s) => s.patchUndoRowId)
const clear = useTableUndoStore((s) => s.clear)
const canUndo = useTableUndoStore((s) => (s.stacks[tableId]?.undo.length ?? 0) > 0)
const canRedo = useTableUndoStore((s) => (s.stacks[tableId]?.redo.length ?? 0) > 0)
const updateRowMutation = useUpdateTableRow({ workspaceId, tableId })
const createRowMutation = useCreateTableRow({ workspaceId, tableId })
const batchCreateRowsMutation = useBatchCreateTableRows({ workspaceId, tableId })
const batchUpdateRowsMutation = useBatchUpdateTableRows({ workspaceId, tableId })
const deleteRowMutation = useDeleteTableRow({ workspaceId, tableId })
const deleteRowsMutation = useDeleteTableRows({ workspaceId, tableId })
const addColumnMutation = useAddTableColumn({ workspaceId, tableId })
const updateColumnMutation = useUpdateColumn({ workspaceId, tableId })
const deleteColumnMutation = useDeleteColumn({ workspaceId, tableId })
const renameTableMutation = useRenameTable(workspaceId)
useEffect(() => {
return () => clear(tableId)
}, [clear, tableId])
const pushUndo = useCallback(
(action: TableUndoAction) => {
push(tableId, action)
},
[push, tableId]
)
const executeAction = useCallback(
(action: TableUndoAction, direction: 'undo' | 'redo') => {
try {
switch (action.type) {
case 'update-cell': {
const value = direction === 'undo' ? action.previousValue : action.newValue
updateRowMutation.mutate({
rowId: action.rowId,
data: { [action.columnName]: value },
})
break
}
case 'clear-cells': {
const updates = action.cells.map((cell) => ({
rowId: cell.rowId,
data:
direction === 'undo'
? cell.data
: Object.fromEntries(Object.keys(cell.data).map((k) => [k, null])),
}))
batchUpdateRowsMutation.mutate({ updates })
break
}
case 'create-row': {
if (direction === 'undo') {
deleteRowMutation.mutate(action.rowId)
} else {
createRowMutation.mutate(
{ data: {}, position: action.position },
{
onSuccess: (response) => {
const newRowId = extractCreatedRowId(response as Record<string, unknown>)
if (newRowId && newRowId !== action.rowId) {
patchUndoRowId(tableId, action.rowId, newRowId)
}
},
}
)
}
break
}
case 'delete-rows': {
if (direction === 'undo') {
batchCreateRowsMutation.mutate(
{
rows: action.rows.map((row) => row.data),
positions: action.rows.map((row) => row.position),
},
{
onSuccess: (response) => {
const createdRows = response?.data?.rows ?? []
for (let i = 0; i < createdRows.length && i < action.rows.length; i++) {
if (createdRows[i].id) {
patchRedoRowId(tableId, action.rows[i].rowId, createdRows[i].id)
}
}
},
}
)
} else {
const rowIds = action.rows.map((r) => r.rowId)
if (rowIds.length === 1) {
deleteRowMutation.mutate(rowIds[0])
} else {
deleteRowsMutation.mutate(rowIds)
}
}
break
}
case 'create-column': {
if (direction === 'undo') {
deleteColumnMutation.mutate(action.columnName)
} else {
addColumnMutation.mutate({
name: action.columnName,
type: 'string',
position: action.position,
})
}
break
}
case 'rename-column': {
if (direction === 'undo') {
updateColumnMutation.mutate({
columnName: action.newName,
updates: { name: action.oldName },
})
} else {
updateColumnMutation.mutate({
columnName: action.oldName,
updates: { name: action.newName },
})
}
break
}
case 'update-column-type': {
const type = direction === 'undo' ? action.previousType : action.newType
updateColumnMutation.mutate({
columnName: action.columnName,
updates: { type },
})
break
}
case 'toggle-column-constraint': {
const value = direction === 'undo' ? action.previousValue : action.newValue
updateColumnMutation.mutate({
columnName: action.columnName,
updates: { [action.constraint]: value },
})
break
}
case 'rename-table': {
const name = direction === 'undo' ? action.previousName : action.newName
renameTableMutation.mutate({ tableId: action.tableId, name })
break
}
}
} catch (err) {
logger.error('Failed to execute undo/redo action', { action, direction, err })
}
},
[tableId, patchRedoRowId, patchUndoRowId]
)
const undo = useCallback(() => {
const entry = popUndo(tableId)
if (!entry) return
runWithoutRecording(() => {
executeAction(entry.action, 'undo')
})
}, [popUndo, tableId, executeAction])
const redo = useCallback(() => {
const entry = popRedo(tableId)
if (!entry) return
runWithoutRecording(() => {
executeAction(entry.action, 'redo')
})
}, [popRedo, tableId, executeAction])
return { pushUndo, undo, redo, canUndo, canRedo }
}

View File

@@ -554,6 +554,36 @@ export async function batchInsertRows(
)
}
const buildRow = (rowData: RowData, position: number) => ({
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
tableId: data.tableId,
workspaceId: data.workspaceId,
data: rowData,
position,
createdAt: now,
updatedAt: now,
...(data.userId ? { createdBy: data.userId } : {}),
})
if (data.positions && data.positions.length > 0) {
// Position-aware insert: shift existing rows to create gaps, then insert.
// Process positions ascending so each shift preserves gaps created by prior shifts.
// (Descending would cause lower shifts to push higher gaps out of position.)
const sortedPositions = [...data.positions].sort((a, b) => a - b)
for (const pos of sortedPositions) {
await trx
.update(userTableRows)
.set({ position: sql`position + 1` })
.where(and(eq(userTableRows.tableId, data.tableId), gte(userTableRows.position, pos)))
}
// Build rows in original input order so RETURNING preserves caller's index correlation
const rowsToInsert = data.rows.map((rowData, i) => buildRow(rowData, data.positions![i]))
return trx.insert(userTableRows).values(rowsToInsert).returning()
}
const [{ maxPos }] = await trx
.select({
maxPos: sql<number>`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number),
@@ -561,16 +591,7 @@ export async function batchInsertRows(
.from(userTableRows)
.where(eq(userTableRows.tableId, data.tableId))
const rowsToInsert = data.rows.map((rowData, i) => ({
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
tableId: data.tableId,
workspaceId: data.workspaceId,
data: rowData,
position: maxPos + 1 + i,
createdAt: now,
updatedAt: now,
...(data.userId ? { createdBy: data.userId } : {}),
}))
const rowsToInsert = data.rows.map((rowData, i) => buildRow(rowData, maxPos + 1 + i))
return trx.insert(userTableRows).values(rowsToInsert).returning()
})
@@ -1387,7 +1408,7 @@ export async function renameColumn(
.where(eq(userTableDefinitions.id, data.tableId))
await trx.execute(
sql`UPDATE user_table_rows SET data = data - ${actualOldName} || jsonb_build_object(${data.newName}, data->${sql.raw(`'${actualOldName.replace(/'/g, "''")}'`)}) WHERE table_id = ${data.tableId} AND data ? ${actualOldName}`
sql`UPDATE user_table_rows SET data = data - ${actualOldName}::text || jsonb_build_object(${data.newName}::text, data->${sql.raw(`'${actualOldName.replace(/'/g, "''")}'`)}) WHERE table_id = ${data.tableId} AND data ? ${actualOldName}::text`
)
})
@@ -1448,7 +1469,7 @@ export async function deleteColumn(
.where(eq(userTableDefinitions.id, data.tableId))
await trx.execute(
sql`UPDATE user_table_rows SET data = data - ${actualName} WHERE table_id = ${data.tableId} AND data ? ${actualName}`
sql`UPDATE user_table_rows SET data = data - ${actualName}::text WHERE table_id = ${data.tableId} AND data ? ${actualName}::text`
)
})
@@ -1512,7 +1533,7 @@ export async function deleteColumns(
for (const name of namesToDelete) {
await trx.execute(
sql`UPDATE user_table_rows SET data = data - ${name} WHERE table_id = ${data.tableId} AND data ? ${name}`
sql`UPDATE user_table_rows SET data = data - ${name}::text WHERE table_id = ${data.tableId} AND data ? ${name}::text`
)
}
})

View File

@@ -178,6 +178,8 @@ export interface BatchInsertData {
rows: RowData[]
workspaceId: string
userId?: string
/** Optional per-row target positions. Length must equal `rows.length`. */
positions?: number[]
}
export interface UpsertRowData {

View File

@@ -0,0 +1,137 @@
/**
* Zustand store for table undo/redo stacks.
* Ephemeral — no persistence. Stacks are keyed by tableId.
*/
import { nanoid } from 'nanoid'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import type { TableUndoAction, TableUndoStacks, TableUndoState, UndoEntry } from './types'
const STACK_CAPACITY = 100
const EMPTY_STACKS: TableUndoStacks = { undo: [], redo: [] }
let undoRedoInProgress = false
/**
* Run a function without recording undo entries.
* Used by the hook when executing undo/redo mutations to prevent recursive recording.
*/
export function runWithoutRecording<T>(fn: () => T): T {
undoRedoInProgress = true
try {
return fn()
} finally {
undoRedoInProgress = false
}
}
export const useTableUndoStore = create<TableUndoState>()(
devtools(
(set, get) => ({
stacks: {},
push: (tableId: string, action: TableUndoAction) => {
if (undoRedoInProgress) return
const entry: UndoEntry = { id: nanoid(), action, timestamp: Date.now() }
set((state) => {
const current = state.stacks[tableId] ?? EMPTY_STACKS
const undoStack = [entry, ...current.undo].slice(0, STACK_CAPACITY)
return {
stacks: {
...state.stacks,
[tableId]: { undo: undoStack, redo: [] },
},
}
})
},
popUndo: (tableId: string) => {
const current = get().stacks[tableId] ?? EMPTY_STACKS
if (current.undo.length === 0) return null
const [entry, ...rest] = current.undo
set((state) => ({
stacks: {
...state.stacks,
[tableId]: {
undo: rest,
redo: [entry, ...current.redo],
},
},
}))
return entry
},
popRedo: (tableId: string) => {
const current = get().stacks[tableId] ?? EMPTY_STACKS
if (current.redo.length === 0) return null
const [entry, ...rest] = current.redo
set((state) => ({
stacks: {
...state.stacks,
[tableId]: {
undo: [entry, ...current.undo],
redo: rest,
},
},
}))
return entry
},
patchRedoRowId: (tableId: string, oldRowId: string, newRowId: string) => {
const stacks = get().stacks[tableId]
if (!stacks) return
const patchedRedo = stacks.redo.map((entry) => {
const { action } = entry
if (action.type === 'delete-rows') {
const patchedRows = action.rows.map((r) =>
r.rowId === oldRowId ? { ...r, rowId: newRowId } : r
)
return { ...entry, action: { ...action, rows: patchedRows } }
}
return entry
})
set((state) => ({
stacks: {
...state.stacks,
[tableId]: { ...stacks, redo: patchedRedo },
},
}))
},
patchUndoRowId: (tableId: string, oldRowId: string, newRowId: string) => {
const stacks = get().stacks[tableId]
if (!stacks) return
const patchedUndo = stacks.undo.map((entry) => {
const { action } = entry
if (action.type === 'create-row' && action.rowId === oldRowId) {
return { ...entry, action: { ...action, rowId: newRowId } }
}
return entry
})
set((state) => ({
stacks: {
...state.stacks,
[tableId]: { ...stacks, undo: patchedUndo },
},
}))
},
clear: (tableId: string) => {
set((state) => {
const { [tableId]: _, ...rest } = state.stacks
return { stacks: rest }
})
},
}),
{ name: 'table-undo-store' }
)
)

View File

@@ -0,0 +1,53 @@
/**
* Type definitions for table undo/redo actions.
*/
export interface DeletedRowSnapshot {
rowId: string
data: Record<string, unknown>
position: number
}
export type TableUndoAction =
| {
type: 'update-cell'
rowId: string
columnName: string
previousValue: unknown
newValue: unknown
}
| { type: 'clear-cells'; cells: Array<{ rowId: string; data: Record<string, unknown> }> }
| { type: 'create-row'; rowId: string; position: number }
| { type: 'delete-rows'; rows: DeletedRowSnapshot[] }
| { type: 'create-column'; columnName: string; position: number }
| { type: 'rename-column'; oldName: string; newName: string }
| { type: 'update-column-type'; columnName: string; previousType: string; newType: string }
| {
type: 'toggle-column-constraint'
columnName: string
constraint: 'unique' | 'required'
previousValue: boolean
newValue: boolean
}
| { type: 'rename-table'; tableId: string; previousName: string; newName: string }
export interface UndoEntry {
id: string
action: TableUndoAction
timestamp: number
}
export interface TableUndoStacks {
undo: UndoEntry[]
redo: UndoEntry[]
}
export interface TableUndoState {
stacks: Record<string, TableUndoStacks>
push: (tableId: string, action: TableUndoAction) => void
popUndo: (tableId: string) => UndoEntry | null
popRedo: (tableId: string) => UndoEntry | null
patchRedoRowId: (tableId: string, oldRowId: string, newRowId: string) => void
patchUndoRowId: (tableId: string, oldRowId: string, newRowId: string) => void
clear: (tableId: string) => void
}