mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement: tables, chat
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface ContextMenuState {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
row: TableRow | null
|
||||
columnName: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
32
apps/sim/components/emcn/icons/fingerprint.tsx
Normal file
32
apps/sim/components/emcn/icons/fingerprint.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user