mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]'>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ChunkEditor } from './chunk-editor'
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -198,6 +198,7 @@ export function Tables() {
|
||||
}}
|
||||
onDelete={() => setIsDeleteDialogOpen(true)}
|
||||
disableDelete={userPermissions.canEdit !== true}
|
||||
disableRename={userPermissions.canEdit !== true}
|
||||
/>
|
||||
|
||||
<Modal open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -981,6 +981,8 @@ export const Sidebar = memo(function Sidebar() {
|
||||
activeTaskId && activeTaskId !== 'new' ? handleStartTaskRename : undefined
|
||||
}
|
||||
onDelete={activeTaskId ? handleDeleteTask : undefined}
|
||||
disableRename={!canEdit}
|
||||
disableDelete={!canEdit}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
221
apps/sim/hooks/use-table-undo.ts
Normal file
221
apps/sim/hooks/use-table-undo.ts
Normal 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 }
|
||||
}
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
137
apps/sim/stores/table/store.ts
Normal file
137
apps/sim/stores/table/store.ts
Normal 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' }
|
||||
)
|
||||
)
|
||||
53
apps/sim/stores/table/types.ts
Normal file
53
apps/sim/stores/table/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user