improvement: tables, chat

This commit is contained in:
Emir Karabeg
2026-03-09 18:23:43 -07:00
parent 5dc026c72e
commit d815568315
12 changed files with 239 additions and 131 deletions

View File

@@ -7,7 +7,7 @@ import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import type { RowData } from '@/lib/table'
import { updateRow } from '@/lib/table'
import { deleteRow, updateRow } from '@/lib/table'
import { accessError, checkAccess } from '@/app/api/table/utils'
const logger = createLogger('TableRowAPI')
@@ -243,22 +243,7 @@ export async function DELETE(request: NextRequest, { params }: RowRouteParams) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const [deletedRow] = await db
.delete(userTableRows)
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId)
)
)
.returning()
if (!deletedRow) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
logger.info(`[${requestId}] Deleted row ${rowId} from table ${tableId}`)
await deleteRow(tableId, rowId, validated.workspaceId, requestId)
return NextResponse.json({
success: true,
@@ -275,6 +260,12 @@ export async function DELETE(request: NextRequest, { params }: RowRouteParams) {
)
}
const errorMessage = error instanceof Error ? error.message : String(error)
if (errorMessage === 'Row not found') {
return NextResponse.json({ error: errorMessage }, { status: 404 })
}
logger.error(`[${requestId}] Error deleting row:`, error)
return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 })
}

View File

@@ -166,7 +166,7 @@ export function Files() {
const [creatingFile, setCreatingFile] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [showPreview, setShowPreview] = useState(false)
const [showPreview, setShowPreview] = useState(true)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [contextMenuFile, setContextMenuFile] = useState<WorkspaceFileRecord | null>(null)
@@ -410,6 +410,7 @@ export function Files() {
if (justCreatedFileIdRef.current && selectedFileId !== justCreatedFileIdRef.current) {
justCreatedFileIdRef.current = null
}
setShowPreview(true)
}, [selectedFileId])
useEffect(() => {

View File

@@ -100,9 +100,18 @@ function mapStoredMessage(msg: TaskStoredMessage): ChatMessage {
}
if (Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0) {
mapped.contentBlocks = msg.toolCalls.map(mapStoredToolCall)
const blocks: ContentBlock[] = msg.toolCalls.map(mapStoredToolCall)
if (msg.content?.trim()) {
blocks.push({ type: 'text', content: msg.content })
}
mapped.contentBlocks = blocks
} else if (Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0) {
mapped.contentBlocks = msg.contentBlocks.map(mapStoredBlock)
const blocks = msg.contentBlocks.map(mapStoredBlock)
const hasText = blocks.some((b) => b.type === 'text' && b.content?.trim())
if (!hasText && msg.content?.trim()) {
blocks.push({ type: 'text', content: msg.content })
}
mapped.contentBlocks = blocks
}
return mapped

View File

@@ -1,38 +1,36 @@
import { ArrowDown, ArrowUp, Edit, Trash2 } from 'lucide-react'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/emcn'
import { ArrowDown, ArrowUp, Pencil, Trash } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import type { ContextMenuState } from '../../types'
const MENU_ITEM =
'relative flex cursor-default select-none items-center gap-[8px] rounded-[5px] px-[8px] py-[5px] font-medium text-[12px] text-[var(--text-secondary)] outline-none transition-colors hover:bg-[var(--surface-4)] hover:text-[var(--text-primary)] [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0'
const MENU_SEPARATOR = '-mx-[6px] my-[6px] h-px bg-[var(--border-1)]'
interface ContextMenuProps {
contextMenu: ContextMenuState
onClose: () => void
onEdit: () => void
onEditCell: () => void
onDelete: () => void
onInsertAbove: () => void
onInsertBelow: () => void
selectedRowCount?: number
}
export function ContextMenu({
contextMenu,
onClose,
onEdit,
onEditCell,
onDelete,
onInsertAbove,
onInsertBelow,
selectedRowCount = 1,
}: ContextMenuProps) {
const deleteLabel = selectedRowCount > 1 ? `Delete ${selectedRowCount} rows` : 'Delete row'
return (
<Popover
open={contextMenu.isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<Popover open={contextMenu.isOpen} onOpenChange={(open) => !open && onClose()}>
<PopoverAnchor
style={{
position: 'fixed',
@@ -42,24 +40,36 @@ export function ContextMenu({
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={onEdit}>
<Edit className='mr-[8px] h-[12px] w-[12px]' />
Edit row
</PopoverItem>
<PopoverItem onClick={onInsertAbove}>
<ArrowUp className='mr-[8px] h-[12px] w-[12px]' />
<PopoverContent
align='start'
side='bottom'
sideOffset={4}
border
className='!min-w-[160px] !rounded-[8px] !bg-[var(--bg)] !p-[6px] shadow-sm'
>
{contextMenu.columnName && (
<div className={MENU_ITEM} onClick={onEditCell} role='menuitem'>
<Pencil />
Edit cell
</div>
)}
<div className={MENU_ITEM} onClick={onInsertAbove} role='menuitem'>
<ArrowUp />
Insert row above
</PopoverItem>
<PopoverItem onClick={onInsertBelow}>
<ArrowDown className='mr-[8px] h-[12px] w-[12px]' />
</div>
<div className={MENU_ITEM} onClick={onInsertBelow} role='menuitem'>
<ArrowDown />
Insert row below
</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={onDelete} className='text-[var(--text-error)]'>
<Trash2 className='mr-[8px] h-[12px] w-[12px]' />
Delete row
</PopoverItem>
</div>
<div className={MENU_SEPARATOR} role='separator' />
<div
className={cn(MENU_ITEM, 'text-[var(--text-error)] hover:text-[var(--text-error)]')}
onClick={onDelete}
role='menuitem'
>
<Trash />
{deleteLabel}
</div>
</PopoverContent>
</Popover>
)

View File

@@ -2,7 +2,6 @@
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { AlertCircle } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -148,50 +147,32 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
onClose()
}
// Delete mode UI
if (mode === 'delete') {
const deleteCount = rowIds?.length ?? (row ? 1 : 0)
const isSingleRow = deleteCount === 1
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent className='w-[480px]'>
<ModalHeader>
<div className='flex items-center gap-[10px]'>
<div className='flex h-[36px] w-[36px] items-center justify-center rounded-[8px] bg-[var(--bg-error)] text-[var(--text-error)]'>
<AlertCircle className='h-[18px] w-[18px]' />
</div>
<h2 className='font-semibold text-[16px]'>
Delete {isSingleRow ? 'Row' : `${deleteCount} Rows`}
</h2>
</div>
</ModalHeader>
<ModalContent size='sm'>
<ModalHeader>Delete {isSingleRow ? 'Row' : `${deleteCount} Rows`}</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[16px]'>
<ErrorMessage error={error} />
<p className='text-[14px] text-[var(--text-secondary)]'>
Are you sure you want to delete {isSingleRow ? 'this row' : 'these rows'}? This
action cannot be undone.
</p>
</div>
{error && (
<div className='rounded-[8px] border border-[var(--status-error-border)] bg-[var(--status-error-bg)] px-[14px] py-[12px] text-[13px] text-[var(--status-error-text)]'>
{error}
</div>
)}
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
{isSingleRow ? 'this row' : `these ${deleteCount} rows`}? This will permanently remove
all data in {isSingleRow ? 'this row' : 'these rows'}.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter className='gap-[10px]'>
<Button
type='button'
variant='default'
onClick={handleClose}
className='min-w-[90px]'
disabled={isSubmitting}
>
<ModalFooter>
<Button variant='default' onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
type='button'
variant='destructive'
onClick={handleDelete}
disabled={isSubmitting}
className='min-w-[120px]'
>
<Button variant='destructive' onClick={handleDelete} disabled={isSubmitting}>
{isSubmitting ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>

View File

@@ -23,10 +23,9 @@ import {
import {
ArrowLeft,
ArrowRight,
Asterisk,
Calendar as CalendarIcon,
ChevronDown,
Key,
Fingerprint,
Pencil,
Plus,
Table as TableIcon,
@@ -315,17 +314,48 @@ export function Table({
}
}, [deleteTableMutation, tableId, router, workspaceId])
const handleContextMenuEdit = useCallback(() => {
if (contextMenu.row) {
setEditingRow(contextMenu.row)
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] },
})
} else if (column) {
setEditingCell({ rowId: contextMenu.row.id, columnName: contextMenu.columnName })
setInitialCharacter(null)
}
}
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
}, [contextMenu.row, contextMenu.columnName, closeContextMenu])
const handleContextMenuDelete = useCallback(() => {
if (contextMenu.row) {
if (!contextMenu.row) {
closeContextMenu()
return
}
const sel = computeNormalizedSelection(selectionAnchorRef.current, selectionFocusRef.current)
const isInSelection =
sel !== null &&
contextMenu.row.position >= sel.startRow &&
contextMenu.row.position <= sel.endRow
if (isInSelection && sel) {
const pMap = positionMapRef.current
const rowIds: string[] = []
for (let r = sel.startRow; r <= sel.endRow; r++) {
const row = pMap.get(r)
if (row) rowIds.push(row.id)
}
if (rowIds.length > 0) {
setDeletingRows(rowIds)
}
} else {
setDeletingRows([contextMenu.row.id])
}
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
@@ -344,7 +374,13 @@ export function Table({
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, row: TableRowType) => {
setEditingCell(null)
baseHandleRowContextMenu(e, row)
const td = (e.target as HTMLElement).closest('td[data-col]') as HTMLElement | null
const colIndex = td ? Number.parseInt(td.getAttribute('data-col') || '-1', 10) : -1
const columnName =
colIndex >= 0 && colIndex < columnsRef.current.length
? columnsRef.current[colIndex].name
: null
baseHandleRowContextMenu(e, row, columnName)
},
[baseHandleRowContextMenu]
)
@@ -886,12 +922,6 @@ export function Table({
updateColumnMutation.mutate({ columnName, updates: { unique: !column.unique } })
}, [])
const handleToggleRequired = useCallback((columnName: string) => {
const column = columnsRef.current.find((c) => c.name === columnName)
if (!column) return
updateColumnMutation.mutate({ columnName, updates: { required: !column.required } })
}, [])
const handleDeleteColumn = useCallback((columnName: string) => {
setDeletingColumn(columnName)
}, [])
@@ -942,6 +972,23 @@ export function Table({
[columnOptions, activeSortState, handleSortChange, handleSortClear]
)
const selectedRowCount = useMemo(() => {
if (!contextMenu.isOpen || !contextMenu.row) return 1
const sel = normalizedSelection
if (!sel) return 1
const isInSelection =
contextMenu.row.position >= sel.startRow && contextMenu.row.position <= sel.endRow
if (!isInSelection) return 1
let count = 0
for (let r = sel.startRow; r <= sel.endRow; r++) {
if (positionMap.has(r)) count++
}
return Math.max(count, 1)
}, [contextMenu.isOpen, contextMenu.row, normalizedSelection, positionMap])
if (!isLoadingTable && !tableData) {
return (
<div className='flex h-full items-center justify-center'>
@@ -1082,7 +1129,6 @@ export function Table({
onInsertLeft={handleInsertColumnLeft}
onInsertRight={handleInsertColumnRight}
onToggleUnique={handleToggleUnique}
onToggleRequired={handleToggleRequired}
onDeleteColumn={handleDeleteColumn}
onResizeStart={handleColumnResizeStart}
onResize={handleColumnResize}
@@ -1219,10 +1265,11 @@ export function Table({
<ContextMenu
contextMenu={contextMenu}
onClose={closeContextMenu}
onEdit={handleContextMenuEdit}
onEditCell={handleContextMenuEditCell}
onDelete={handleContextMenuDelete}
onInsertAbove={handleInsertRowAbove}
onInsertBelow={handleInsertRowBelow}
selectedRowCount={selectedRowCount}
/>
{!embedded && (
@@ -2162,7 +2209,6 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
onInsertLeft,
onInsertRight,
onToggleUnique,
onToggleRequired,
onDeleteColumn,
onResizeStart,
onResize,
@@ -2179,7 +2225,6 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
onInsertLeft: (columnName: string) => void
onInsertRight: (columnName: string) => void
onToggleUnique: (columnName: string) => void
onToggleRequired: (columnName: string) => void
onDeleteColumn: (columnName: string) => void
onResizeStart: (columnName: string) => void
onResize: (columnName: string, width: number) => void
@@ -2291,13 +2336,9 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onToggleUnique(column.name)}>
<Key />
<Fingerprint />
{column.unique ? 'Remove unique' : 'Set unique'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onToggleRequired(column.name)}>
<Asterisk />
{column.required ? 'Remove required' : 'Set required'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => onDeleteColumn(column.name)}

View File

@@ -4,7 +4,7 @@ import type { ContextMenuState } from '../types'
interface UseContextMenuReturn {
contextMenu: ContextMenuState
handleRowContextMenu: (e: React.MouseEvent, row: TableRow) => void
handleRowContextMenu: (e: React.MouseEvent, row: TableRow, columnName?: string | null) => void
closeContextMenu: () => void
}
@@ -13,17 +13,22 @@ export function useContextMenu(): UseContextMenuReturn {
isOpen: false,
position: { x: 0, y: 0 },
row: null,
columnName: null,
})
const handleRowContextMenu = useCallback((e: React.MouseEvent, row: TableRow) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({
isOpen: true,
position: { x: e.clientX, y: e.clientY },
row,
})
}, [])
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, row: TableRow, columnName?: string | null) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({
isOpen: true,
position: { x: e.clientX, y: e.clientY },
row,
columnName: columnName ?? null,
})
},
[]
)
const closeContextMenu = useCallback(() => {
setContextMenu((prev) => ({ ...prev, isOpen: false }))

View File

@@ -20,6 +20,7 @@ export interface ContextMenuState {
isOpen: boolean
position: { x: number; y: number }
row: TableRow | null
columnName: string | null
}
/**

View File

@@ -0,0 +1,32 @@
import type { SVGProps } from 'react'
/**
* Fingerprint icon component — unique constraint indicator
* @param props - SVG properties including className, fill, etc.
*/
export function Fingerprint(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4' />
<path d='M14 13.12c0 2.38 0 6.38-1 8.88' />
<path d='M17.29 21.02c.12-.6.43-2.3.5-3.02' />
<path d='M2 12a10 10 0 0 1 18-6' />
<path d='M2 16h.01' />
<path d='M21.8 16c.2-2 .131-5.354 0-6' />
<path d='M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2' />
<path d='M8.65 22c.21-.66.45-1.32.57-2' />
<path d='M9 6.8a6 6 0 0 1 9 5.2v2' />
</svg>
)
}

View File

@@ -22,6 +22,7 @@ export { Duplicate } from './duplicate'
export { Expand } from './expand'
export { Eye } from './eye'
export { File } from './file'
export { Fingerprint } from './fingerprint'
export { FolderCode } from './folder-code'
export { FolderPlus } from './folder-plus'
export { Hand } from './hand'

View File

@@ -1,7 +1,7 @@
import type { SVGProps } from 'react'
/**
* Type boolean icon component - toggle switch for boolean columns
* Type boolean icon component - checkbox for boolean columns
* @param props - SVG properties including className, fill, etc.
*/
export function TypeBoolean(props: SVGProps<SVGSVGElement>) {
@@ -18,8 +18,8 @@ export function TypeBoolean(props: SVGProps<SVGSVGElement>) {
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<rect x='1.25' y='5.25' width='18' height='10.5' rx='5.25' />
<circle cx='6.5' cy='10.5' r='3' />
<rect x='2.5' y='2.75' width='15.5' height='15.5' rx='2.5' />
<path d='M6.25 10.75L9.25 13.75L14.25 7.25' />
</svg>
)
}

View File

@@ -10,7 +10,7 @@
import { db } from '@sim/db'
import { userTableDefinitions, userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq, gte, sql } from 'drizzle-orm'
import { and, count, eq, gt, gte, sql } from 'drizzle-orm'
import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from './constants'
import { buildFilterClause, buildSortClause } from './sql'
import type {
@@ -959,12 +959,25 @@ export async function deleteRow(
workspaceId: string,
requestId: string
): Promise<void> {
const existingRow = await getRowById(tableId, rowId, workspaceId)
if (!existingRow) {
throw new Error('Row not found')
}
await db.transaction(async (trx) => {
const [deleted] = await trx
.delete(userTableRows)
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, workspaceId)
)
)
.returning({ position: userTableRows.position })
await db.delete(userTableRows).where(eq(userTableRows.id, rowId))
if (!deleted) throw new Error('Row not found')
await trx
.update(userTableRows)
.set({ position: sql`position - 1` })
.where(and(eq(userTableRows.tableId, tableId), gt(userTableRows.position, deleted.position)))
})
logger.info(`[${requestId}] Deleted row ${rowId} from table ${tableId}`)
}
@@ -1079,6 +1092,25 @@ export async function updateRowsByFilter(
}
}
type DbTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0]
/**
* Recompacts row positions to be contiguous (0, 1, 2, ...) after batch deletions.
* Single-row deletes use the more efficient `position - 1` shift in {@link deleteRow}.
*/
async function recompactPositions(tableId: string, trx: DbTransaction) {
await trx.execute(sql`
UPDATE user_table_rows t
SET position = r.new_pos
FROM (
SELECT id, ROW_NUMBER() OVER (ORDER BY position) - 1 AS new_pos
FROM user_table_rows
WHERE table_id = ${tableId}
) r
WHERE t.id = r.id AND t.table_id = ${tableId} AND t.position != r.new_pos
`)
}
/**
* Deletes multiple rows matching a filter.
*
@@ -1121,7 +1153,6 @@ export async function deleteRowsByFilter(
const rowIds = matchingRows.map((r) => r.id)
// Delete in batches
await db.transaction(async (trx) => {
for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) {
const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE)
@@ -1136,6 +1167,8 @@ export async function deleteRowsByFilter(
)
)
}
await recompactPositions(data.tableId, trx)
})
logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${data.tableId}`)
@@ -1178,6 +1211,9 @@ export async function deleteRowsByIds(
.returning({ id: userTableRows.id })
deleted.push(...rows)
}
await recompactPositions(data.tableId, trx)
return deleted
})