From eba424c8a3799cbace8aa68df44d6e5c7ea21783 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Apr 2026 20:03:03 -0700 Subject: [PATCH 01/11] improvement(tables): ops and experience --- .../app/api/schedules/execute/route.test.ts | 60 +- .../components/context-menu/context-menu.tsx | 2 +- .../components/row-modal/row-modal.tsx | 5 +- .../components/table-filter/index.tsx | 1 + .../components/table-filter/table-filter.tsx | 36 +- .../[tableId]/components/table/table.tsx | 615 +++++++++++++----- .../tables/[tableId]/export.test.ts | 39 ++ .../[workspaceId]/tables/[tableId]/export.ts | 38 ++ .../tables/[tableId]/hooks/index.ts | 1 + .../[tableId]/hooks/use-context-menu.ts | 2 +- .../[tableId]/hooks/use-export-table.ts | 84 +++ .../tables/[tableId]/hooks/use-table-data.ts | 5 +- .../emcn/components/modal/modal.tsx | 11 +- apps/sim/hooks/queries/tables.ts | 49 +- .../workflow/edit-workflow/builders.test.ts | 23 +- .../workflow/edit-workflow/operations.test.ts | 67 +- .../workflow/edit-workflow/validation.test.ts | 24 +- .../core/rate-limiter/rate-limiter.test.ts | 4 +- apps/sim/lib/core/utils/file-download.ts | 36 + apps/sim/lib/mcp/connection-manager.test.ts | 4 +- apps/sim/lib/posthog/events.ts | 9 + .../lib/table/__tests__/validation.test.ts | 58 +- .../lib/workflows/comparison/compare.test.ts | 275 ++++---- apps/sim/lib/workflows/lifecycle.test.ts | 32 +- .../lib/workflows/operations/import-export.ts | 33 +- apps/sim/lib/workspaces/lifecycle.test.ts | 36 +- packages/testing/src/factories/index.ts | 16 + .../src/factories/table.factory.test.ts | 12 + .../testing/src/factories/table.factory.ts | 62 ++ .../factories/workflow-variable.factory.ts | 53 ++ packages/testing/src/index.ts | 8 + packages/testing/src/mocks/database.mock.ts | 32 + .../testing/src/mocks/edit-workflow.mock.ts | 47 ++ .../testing/src/mocks/feature-flags.mock.ts | 65 ++ packages/testing/src/mocks/index.ts | 32 +- packages/testing/src/mocks/request.mock.ts | 7 + 36 files changed, 1307 insertions(+), 576 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/export.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/export.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-export-table.ts create mode 100644 apps/sim/lib/core/utils/file-download.ts create mode 100644 packages/testing/src/factories/table.factory.test.ts create mode 100644 packages/testing/src/factories/table.factory.ts create mode 100644 packages/testing/src/factories/workflow-variable.factory.ts create mode 100644 packages/testing/src/mocks/edit-workflow.mock.ts create mode 100644 packages/testing/src/mocks/feature-flags.mock.ts diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index 80c59e537d..582f831a30 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -3,14 +3,14 @@ * * @vitest-environment node */ -import type { NextRequest } from 'next/server' +import { createFeatureFlagsMock, createMockRequest } from '@sim/testing' +import { drizzleOrmMock } from '@sim/testing/mocks' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockVerifyCronAuth, mockExecuteScheduleJob, mockExecuteJobInline, - mockFeatureFlags, mockDbReturning, mockDbUpdate, mockEnqueue, @@ -33,12 +33,6 @@ const { mockVerifyCronAuth: vi.fn().mockReturnValue(null), mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined), mockExecuteJobInline: vi.fn().mockResolvedValue(undefined), - mockFeatureFlags: { - isTriggerDevEnabled: false, - isHosted: false, - isProd: false, - isDev: true, - }, mockDbReturning, mockDbUpdate, mockEnqueue, @@ -49,6 +43,13 @@ const { } }) +const mockFeatureFlags = createFeatureFlagsMock({ + isTriggerDevEnabled: false, + isHosted: false, + isProd: false, + isDev: true, +}) + vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth, })) @@ -91,17 +92,7 @@ vi.mock('@/lib/workflows/utils', () => ({ }), })) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), - ne: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'ne' })), - lte: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'lte' })), - lt: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'lt' })), - not: vi.fn((condition: unknown) => ({ type: 'not', condition })), - isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), - or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), - sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', strings, values })), -})) +vi.mock('drizzle-orm', () => drizzleOrmMock) vi.mock('@sim/db', () => ({ db: { @@ -177,18 +168,13 @@ const SINGLE_JOB = [ }, ] -function createMockRequest(): NextRequest { - const mockHeaders = new Map([ - ['authorization', 'Bearer test-cron-secret'], - ['content-type', 'application/json'], - ]) - - return { - headers: { - get: (key: string) => mockHeaders.get(key.toLowerCase()) || null, - }, - url: 'http://localhost:3000/api/schedules/execute', - } as NextRequest +function createCronRequest() { + return createMockRequest( + 'GET', + undefined, + { Authorization: 'Bearer test-cron-secret' }, + 'http://localhost:3000/api/schedules/execute' + ) } describe('Scheduled Workflow Execution API Route', () => { @@ -204,7 +190,7 @@ describe('Scheduled Workflow Execution API Route', () => { it('should execute scheduled workflows with Trigger.dev disabled', async () => { mockDbReturning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([]) - const response = await GET(createMockRequest()) + const response = await GET(createCronRequest() as any) expect(response).toBeDefined() expect(response.status).toBe(200) @@ -217,7 +203,7 @@ describe('Scheduled Workflow Execution API Route', () => { mockFeatureFlags.isTriggerDevEnabled = true mockDbReturning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([]) - const response = await GET(createMockRequest()) + const response = await GET(createCronRequest() as any) expect(response).toBeDefined() expect(response.status).toBe(200) @@ -228,7 +214,7 @@ describe('Scheduled Workflow Execution API Route', () => { it('should handle case with no due schedules', async () => { mockDbReturning.mockReturnValueOnce([]).mockReturnValueOnce([]) - const response = await GET(createMockRequest()) + const response = await GET(createCronRequest() as any) expect(response.status).toBe(200) const data = await response.json() @@ -239,7 +225,7 @@ describe('Scheduled Workflow Execution API Route', () => { it('should execute multiple schedules in parallel', async () => { mockDbReturning.mockReturnValueOnce(MULTIPLE_SCHEDULES).mockReturnValueOnce([]) - const response = await GET(createMockRequest()) + const response = await GET(createCronRequest() as any) expect(response.status).toBe(200) const data = await response.json() @@ -249,7 +235,7 @@ describe('Scheduled Workflow Execution API Route', () => { it('should queue mothership jobs to BullMQ when available', async () => { mockDbReturning.mockReturnValueOnce([]).mockReturnValueOnce(SINGLE_JOB) - const response = await GET(createMockRequest()) + const response = await GET(createCronRequest() as any) expect(response.status).toBe(200) expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith( @@ -274,7 +260,7 @@ describe('Scheduled Workflow Execution API Route', () => { it('should enqueue preassigned correlation metadata for schedules', async () => { mockDbReturning.mockReturnValue(SINGLE_SCHEDULE) - const response = await GET(createMockRequest()) + const response = await GET(createCronRequest() as any) expect(response.status).toBe(200) expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index 9939e3cd2f..30f75e4934 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -6,7 +6,7 @@ import { DropdownMenuTrigger, } from '@/components/emcn' import { ArrowDown, ArrowUp, Duplicate, Pencil, Trash } from '@/components/emcn/icons' -import type { ContextMenuState } from '../../types' +import type { ContextMenuState } from '@/app/workspace/[workspaceId]/tables/[tableId]/types' interface ContextMenuProps { contextMenu: ContextMenuState diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx index b506f79230..758b1b64c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx @@ -17,13 +17,16 @@ import { Textarea, } from '@/components/emcn' import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table' +import { + cleanCellValue, + formatValueForInput, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/utils' import { useCreateTableRow, useDeleteTableRow, useDeleteTableRows, useUpdateTableRow, } from '@/hooks/queries/tables' -import { cleanCellValue, formatValueForInput } from '../../utils' const logger = createLogger('RowModal') diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.tsx index 8cd08769fe..7fd96b9613 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.tsx @@ -1 +1,2 @@ +export type { TableFilterHandle } from './table-filter' export { TableFilter } from './table-filter' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx index 07d5046283..ded03d9499 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx @@ -1,6 +1,14 @@ 'use client' -import { memo, useCallback, useMemo, useRef, useState } from 'react' +import { + forwardRef, + memo, + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' import { X } from 'lucide-react' import { nanoid } from 'nanoid' import { @@ -19,22 +27,42 @@ const OPERATOR_LABELS = Object.fromEntries( COMPARISON_OPERATORS.map((op) => [op.value, op.label]) ) as Record +export interface TableFilterHandle { + addColumnRule: (columnName: string) => void +} + interface TableFilterProps { columns: Array<{ name: string; type: string }> filter: Filter | null onApply: (filter: Filter | null) => void onClose: () => void + initialColumn?: string | null } -export function TableFilter({ columns, filter, onApply, onClose }: TableFilterProps) { +export const TableFilter = forwardRef(function TableFilter( + { columns, filter, onApply, onClose, initialColumn }, + ref +) { const [rules, setRules] = useState(() => { const fromFilter = filterToRules(filter) - return fromFilter.length > 0 ? fromFilter : [createRule(columns)] + if (fromFilter.length > 0) return fromFilter + const rule = createRule(columns) + return [initialColumn ? { ...rule, column: initialColumn } : rule] }) const rulesRef = useRef(rules) rulesRef.current = rules + useImperativeHandle( + ref, + () => ({ + addColumnRule: (columnName: string) => { + setRules((prev) => [...prev, { ...createRule(columns), column: columnName }]) + }, + }), + [columns] + ) + const columnOptions = useMemo( () => columns.map((col) => ({ value: col.name, label: col.name })), [columns] @@ -125,7 +153,7 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr ) -} +}) interface FilterRuleRowProps { rule: FilterRule diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 843488172c..387643a245 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -1,7 +1,6 @@ 'use client' -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { GripVertical } from 'lucide-react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { @@ -24,11 +23,15 @@ import { Skeleton, } from '@/components/emcn' import { + ArrowDown, ArrowLeft, ArrowRight, + ArrowUp, Calendar as CalendarIcon, ChevronDown, + Download, Fingerprint, + ListFilter, Pencil, Plus, Table as TableIcon, @@ -45,6 +48,26 @@ import type { ColumnDefinition, Filter, SortDirection, TableRow as TableRowType import type { ColumnOption, SortConfig } from '@/app/workspace/[workspaceId]/components' import { ResourceHeader, ResourceOptionsBar } from '@/app/workspace/[workspaceId]/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { ContextMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu' +import { RowModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal' +import type { TableFilterHandle } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter' +import { TableFilter } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter' +import { + useContextMenu, + useExportTable, + useTableData, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks' +import type { + EditingCell, + QueryOptions, + SaveReason, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/types' +import { + cleanCellValue, + displayToStorage, + formatValueForInput, + storageToDisplay, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/utils' import { useAddTableColumn, useBatchCreateTableRows, @@ -60,17 +83,6 @@ import { 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, - displayToStorage, - formatValueForInput, - storageToDisplay, -} from '../../utils' -import { ContextMenu } from '../context-menu' -import { RowModal } from '../row-modal' -import { TableFilter } from '../table-filter' interface CellCoord { rowIndex: number @@ -88,6 +100,7 @@ interface NormalizedSelection { const EMPTY_COLUMNS: never[] = [] const EMPTY_CHECKED_ROWS = new Set() +const clearCheckedRows = (prev: Set) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS) const COL_WIDTH = 160 const COL_WIDTH_MIN = 80 const CHECKBOX_COL_WIDTH = 40 @@ -196,6 +209,7 @@ export function Table({ const [initialCharacter, setInitialCharacter] = useState(null) const [selectionAnchor, setSelectionAnchor] = useState(null) const [selectionFocus, setSelectionFocus] = useState(null) + const [isColumnSelection, setIsColumnSelection] = useState(false) const [checkedRows, setCheckedRows] = useState(EMPTY_CHECKED_ROWS) const lastCheckboxRowRef = useRef(null) const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false) @@ -220,6 +234,8 @@ export function Table({ const metadataSeededRef = useRef(false) const containerRef = useRef(null) const scrollRef = useRef(null) + const ghostRef = useRef(null) + const tableFilterRef = useRef(null) const isDraggingRef = useRef(false) const { tableData, isLoadingTable, rows, isLoadingRows } = useTableData({ @@ -291,10 +307,11 @@ export function Table({ const positionMapRef = useRef(positionMap) positionMapRef.current = positionMap - const normalizedSelection = useMemo( - () => computeNormalizedSelection(selectionAnchor, selectionFocus), - [selectionAnchor, selectionFocus] - ) + const normalizedSelection = useMemo(() => { + const raw = computeNormalizedSelection(selectionAnchor, selectionFocus) + if (!raw || !isColumnSelection) return raw + return { ...raw, startRow: 0, endRow: Math.max(maxPosition, 0) } + }, [selectionAnchor, selectionFocus, isColumnSelection, maxPosition]) const displayColCount = isLoadingTable ? SKELETON_COL_COUNT : displayColumns.length const tableWidth = useMemo(() => { @@ -350,6 +367,7 @@ export function Table({ const rowsRef = useRef(rows) const selectionAnchorRef = useRef(selectionAnchor) const selectionFocusRef = useRef(selectionFocus) + const normalizedSelectionRef = useRef(normalizedSelection) const checkedRowsRef = useRef(checkedRows) checkedRowsRef.current = checkedRows @@ -359,6 +377,7 @@ export function Table({ rowsRef.current = rows selectionAnchorRef.current = selectionAnchor selectionFocusRef.current = selectionFocus + normalizedSelectionRef.current = normalizedSelection const deleteTableMutation = useDeleteTable(workspaceId) const renameTableMutation = useRenameTable(workspaceId) @@ -574,7 +593,8 @@ export function Table({ const handleCellMouseDown = useCallback( (rowIndex: number, colIndex: number, shiftKey: boolean) => { - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setCheckedRows(clearCheckedRows) + setIsColumnSelection(false) lastCheckboxRowRef.current = null if (shiftKey && selectionAnchorRef.current) { setSelectionFocus({ rowIndex, colIndex }) @@ -597,6 +617,7 @@ export function Table({ setEditingCell(null) setSelectionAnchor(null) setSelectionFocus(null) + setIsColumnSelection(false) if (shiftKey && lastCheckboxRowRef.current !== null) { const from = Math.min(lastCheckboxRowRef.current, rowIndex) @@ -627,7 +648,8 @@ export function Table({ const handleClearSelection = useCallback(() => { setSelectionAnchor(null) setSelectionFocus(null) - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setIsColumnSelection(false) + setCheckedRows(clearCheckedRows) lastCheckboxRowRef.current = null }, []) @@ -637,6 +659,7 @@ export function Table({ setEditingCell(null) setSelectionAnchor(null) setSelectionFocus(null) + setIsColumnSelection(false) const all = new Set() for (const row of rws) { all.add(row.position) @@ -666,46 +689,128 @@ export function Table({ updateMetadataRef.current({ columnWidths: columnWidthsRef.current }) }, []) - const handleColumnDragStart = useCallback((columnName: string) => { - setDragColumnName(columnName) - }, []) + const handleColumnDragStart = useCallback( + (columnName: string, element: HTMLElement, pointerId: number, startX: number) => { + element.setPointerCapture(pointerId) - const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => { - if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return - setDropTargetColumnName(columnName) - setDropSide(side) - }, []) + setDragColumnName(columnName) + + const scroll = scrollRef.current + const ghost = ghostRef.current + if (!scroll || !ghost) return - const handleColumnDragEnd = useCallback(() => { - const dragged = dragColumnNameRef.current - if (!dragged) return - const target = dropTargetColumnNameRef.current - const side = dropSideRef.current - if (target && dragged !== target) { const cols = columnsRef.current - const currentOrder = columnOrderRef.current ?? cols.map((c) => c.name) - const fromIndex = currentOrder.indexOf(dragged) - const toIndex = currentOrder.indexOf(target) - if (fromIndex !== -1 && toIndex !== -1) { - const newOrder = currentOrder.filter((n) => n !== dragged) - let insertIndex = newOrder.indexOf(target) - if (side === 'right') insertIndex += 1 - newOrder.splice(insertIndex, 0, dragged) - setColumnOrder(newOrder) - updateMetadataRef.current({ - columnWidths: columnWidthsRef.current, - columnOrder: newOrder, - }) + const widths = columnWidthsRef.current + let left = CHECKBOX_COL_WIDTH + const boundaries: Array<{ name: string; left: number; width: number }> = [] + for (const col of cols) { + const w = widths[col.name] ?? COL_WIDTH + boundaries.push({ name: col.name, left, width: w }) + left += w + } + + const dragged = boundaries.find((b) => b.name === columnName) + if (!dragged) return + + const scrollRect = scroll.getBoundingClientRect() + const tableEl = element.closest('table') + + // Position the ghost at the dragged column's location + ghost.style.left = `${dragged.left}px` + ghost.style.width = `${dragged.width}px` + ghost.style.height = `${tableEl ? tableEl.offsetHeight : scroll.scrollHeight}px` + ghost.style.transform = 'translateX(0)' + ghost.style.display = 'block' + + const commit = () => { + const dragedName = dragColumnNameRef.current + const target = dropTargetColumnNameRef.current + const side = dropSideRef.current + if (dragedName && target && dragedName !== target) { + const currentOrder = columnOrderRef.current ?? columnsRef.current.map((c) => c.name) + const newOrder = currentOrder.filter((n) => n !== dragedName) + let insertIndex = newOrder.indexOf(target) + if (side === 'right') insertIndex += 1 + newOrder.splice(insertIndex, 0, dragedName) + setColumnOrder(newOrder) + updateMetadataRef.current({ + columnWidths: columnWidthsRef.current, + columnOrder: newOrder, + }) + } + } + + const cleanup = (shouldCommit: boolean) => { + element.removeEventListener('pointermove', handleMove) + element.removeEventListener('pointerup', handleUp) + element.removeEventListener('pointercancel', handleCancel) + document.removeEventListener('keydown', handleKeyDown) + element.releasePointerCapture(pointerId) + if (shouldCommit) commit() + ghost.style.display = 'none' + ghost.style.transform = 'translateX(0)' + setDragColumnName(null) + setDropTargetColumnName(null) + setDropSide('left') + } + + const handleMove = (ev: PointerEvent) => { + const delta = ev.clientX - startX + ghost.style.transform = `translateX(${delta}px)` + + // Determine which column the cursor is over + const tableX = ev.clientX - scrollRect.left + scroll.scrollLeft + let target = boundaries[boundaries.length - 1] + let side: 'left' | 'right' = 'right' + for (const b of boundaries) { + if (tableX < b.left + b.width) { + target = b + side = tableX < b.left + b.width / 2 ? 'left' : 'right' + break + } + } + + if (target.name === columnName) { + // Hovering over self — suppress the indicator + if (dropTargetColumnNameRef.current !== null) { + setDropTargetColumnName(null) + } + } else if ( + target.name !== dropTargetColumnNameRef.current || + side !== dropSideRef.current + ) { + setDropTargetColumnName(target.name) + setDropSide(side) + } + } + + const handleKeyDown = (ev: KeyboardEvent) => { + if (ev.key === 'Escape') cleanup(false) } - } - setDragColumnName(null) - setDropTargetColumnName(null) - setDropSide('left') - }, []) - const handleColumnDragLeave = useCallback(() => { - dropTargetColumnNameRef.current = null - setDropTargetColumnName(null) + const handleUp = () => cleanup(true) + const handleCancel = () => cleanup(false) + + element.addEventListener('pointermove', handleMove) + element.addEventListener('pointerup', handleUp) + element.addEventListener('pointercancel', handleCancel) + document.addEventListener('keydown', handleKeyDown) + }, + [] + ) + + const handleColumnSelect = useCallback((colIndex: number, shiftKey: boolean) => { + setCheckedRows(clearCheckedRows) + lastCheckboxRowRef.current = null + setEditingCell(null) + if (shiftKey && selectionAnchorRef.current) { + setSelectionFocus({ rowIndex: 0, colIndex }) + } else { + setSelectionAnchor({ rowIndex: 0, colIndex }) + setSelectionFocus({ rowIndex: 0, colIndex }) + } + setIsColumnSelection(true) + scrollRef.current?.focus({ preventScroll: true }) }, []) useEffect(() => { @@ -782,6 +887,9 @@ export function Table({ const updateMetadataRef = useRef(updateMetadataMutation.mutate) updateMetadataRef.current = updateMetadataMutation.mutate + const addColumnAsyncRef = useRef(addColumnMutation.mutateAsync) + addColumnAsyncRef.current = addColumnMutation.mutateAsync + const toggleBooleanCellRef = useRef(toggleBooleanCell) toggleBooleanCellRef.current = toggleBooleanCell @@ -793,6 +901,17 @@ export function Table({ if (!el) return const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + isDraggingRef.current = false + setSelectionAnchor(null) + setSelectionFocus(null) + setIsColumnSelection(false) + setCheckedRows(clearCheckedRows) + lastCheckboxRowRef.current = null + return + } + const tag = (e.target as HTMLElement).tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return @@ -806,15 +925,6 @@ export function Table({ return } - if (e.key === 'Escape') { - e.preventDefault() - setSelectionAnchor(null) - setSelectionFocus(null) - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) - lastCheckboxRowRef.current = null - return - } - if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault() const rws = rowsRef.current @@ -822,6 +932,7 @@ export function Table({ setEditingCell(null) setSelectionAnchor(null) setSelectionFocus(null) + setIsColumnSelection(false) const all = new Set() for (const row of rws) { all.add(row.position) @@ -835,6 +946,7 @@ export function Table({ const a = selectionAnchorRef.current if (!a || editingCellRef.current) return e.preventDefault() + setIsColumnSelection(false) setSelectionFocus(null) setCheckedRows((prev) => { const next = new Set(prev) @@ -887,6 +999,7 @@ export function Table({ const row = positionMapRef.current.get(anchor.rowIndex) if (!row) return e.preventDefault() + setIsColumnSelection(false) const position = row.position + 1 const colIndex = anchor.colIndex createRef.current( @@ -908,12 +1021,12 @@ export function Table({ if (e.key === 'Enter' || e.key === 'F2') { if (!canEditRef.current) return e.preventDefault() + setIsColumnSelection(false) const col = cols[anchor.colIndex] if (!col) return const row = positionMapRef.current.get(anchor.rowIndex) if (!row) return - if (col.type === 'boolean') { toggleBooleanCellRef.current(row.id, col.name, row.data[col.name]) return @@ -935,7 +1048,8 @@ export function Table({ if (e.key === 'Tab') { e.preventDefault() - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setCheckedRows(clearCheckedRows) + setIsColumnSelection(false) lastCheckboxRowRef.current = null setSelectionAnchor(moveCell(anchor, cols.length, totalRows, e.shiftKey ? -1 : 1)) setSelectionFocus(null) @@ -944,7 +1058,8 @@ export function Table({ if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { e.preventDefault() - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setCheckedRows(clearCheckedRows) + setIsColumnSelection(false) lastCheckboxRowRef.current = null const focus = selectionFocusRef.current ?? anchor const origin = e.shiftKey ? focus : anchor @@ -979,7 +1094,7 @@ export function Table({ if (e.key === 'Delete' || e.key === 'Backspace') { if (!canEditRef.current) return e.preventDefault() - const sel = computeNormalizedSelection(anchor, selectionFocusRef.current) + const sel = normalizedSelectionRef.current if (!sel) return const pMap = positionMapRef.current const undoCells: Array<{ rowId: string; data: Record }> = [] @@ -1011,6 +1126,7 @@ export function Table({ if (col.type === 'number' && !/[\d.-]/.test(e.key)) return if (col.type === 'date' && !/[\d\-/]/.test(e.key)) return e.preventDefault() + setIsColumnSelection(false) const row = positionMapRef.current.get(anchor.rowIndex) if (!row) return @@ -1047,10 +1163,7 @@ export function Table({ return } - const anchor = selectionAnchorRef.current - if (!anchor) return - - const sel = computeNormalizedSelection(anchor, selectionFocusRef.current) + const sel = normalizedSelectionRef.current if (!sel) return e.preventDefault() @@ -1106,10 +1219,7 @@ export function Table({ } e.clipboardData?.setData('text/plain', lines.join('\n')) } else { - const anchor = selectionAnchorRef.current - if (!anchor) return - - const sel = computeNormalizedSelection(anchor, selectionFocusRef.current) + const sel = normalizedSelectionRef.current if (!sel) return e.preventDefault() @@ -1145,7 +1255,7 @@ export function Table({ } } - const handlePaste = (e: ClipboardEvent) => { + const handlePaste = async (e: ClipboardEvent) => { const tag = (e.target as HTMLElement).tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return if (!canEditRef.current) return @@ -1164,8 +1274,44 @@ export function Table({ if (pasteRows.length === 0) return - const currentCols = columnsRef.current + let currentCols = columnsRef.current const pMap = positionMapRef.current + const maxPasteCols = Math.max(...pasteRows.map((pr) => pr.length)) + const neededExtraCols = Math.max( + 0, + currentAnchor.colIndex + maxPasteCols - currentCols.length + ) + + if (neededExtraCols > 0) { + // Generate unique names for the new columns without colliding with each other + const existingNames = new Set(currentCols.map((c) => c.name.toLowerCase())) + const newColNames: string[] = [] + for (let i = 0; i < neededExtraCols; i++) { + let name = 'untitled' + let n = 2 + while (existingNames.has(name.toLowerCase())) { + name = `untitled_${n}` + n++ + } + existingNames.add(name.toLowerCase()) + newColNames.push(name) + } + + // Create columns sequentially so each invalidation completes before the next + try { + for (const name of newColNames) { + await addColumnAsyncRef.current({ name, type: 'string' }) + } + } catch { + // If column creation fails, paste into whatever columns exist + } + + // Build updated column list locally — React Query cache may not have refreshed yet + currentCols = [ + ...currentCols, + ...newColNames.map((name) => ({ name, type: 'string' as const })), + ] + } const undoCells: Array<{ rowId: string; data: Record }> = [] const updateBatch: Array<{ rowId: string; data: Record }> = [] @@ -1245,7 +1391,6 @@ export function Table({ ) } - const maxPasteCols = Math.max(...pasteRows.map((pr) => pr.length)) setSelectionFocus({ rowIndex: currentAnchor.rowIndex + pasteRows.length - 1, colIndex: Math.min(currentAnchor.colIndex + maxPasteCols - 1, currentCols.length - 1), @@ -1429,7 +1574,10 @@ export function Table({ }, []) const handleRenameColumn = useCallback( - (name: string) => columnRename.startRename(name, name), + (name: string) => { + isDraggingRef.current = false + columnRename.startRename(name, name) + }, [columnRename.startRename] ) @@ -1468,13 +1616,28 @@ export function Table({ }, []) const [filterOpen, setFilterOpen] = useState(false) + const [initialFilterColumn, setInitialFilterColumn] = useState(null) const handleFilterToggle = useCallback(() => { + setInitialFilterColumn(null) setFilterOpen((prev) => !prev) }, []) const handleFilterClose = useCallback(() => { setFilterOpen(false) + setInitialFilterColumn(null) + }, []) + + const filterOpenRef = useRef(filterOpen) + filterOpenRef.current = filterOpen + + const handleFilterByColumn = useCallback((columnName: string) => { + if (filterOpenRef.current && tableFilterRef.current) { + tableFilterRef.current.addColumnRule(columnName) + } else { + setInitialFilterColumn(columnName) + setFilterOpen(true) + } }, []) const columnOptions = useMemo( @@ -1555,6 +1718,27 @@ export function Table({ [handleAddColumn, addColumnMutation.isPending] ) + const { handleExportTable, isExporting } = useExportTable({ + workspaceId, + tableId, + tableName: tableData?.name, + columns: displayColumns, + queryOptions, + canExport: userPermissions.canEdit, + }) + + const headerActions = useMemo( + () => [ + { + label: isExporting ? 'Exporting...' : 'Export CSV', + icon: Download, + onClick: () => void handleExportTable(), + disabled: !userPermissions.canEdit || !hasTableData || isLoadingTable || isExporting, + }, + ], + [handleExportTable, hasTableData, isExporting, isLoadingTable, userPermissions.canEdit] + ) + const activeSortState = useMemo(() => { if (!queryOptions.sort) return null const entries = Object.entries(queryOptions.sort) @@ -1563,6 +1747,26 @@ export function Table({ return { column, direction } }, [queryOptions.sort]) + const selectedColumnRange = useMemo(() => { + if (!isColumnSelection || !normalizedSelection) return null + return { start: normalizedSelection.startCol, end: normalizedSelection.endCol } + }, [isColumnSelection, normalizedSelection]) + + const draggingColIndex = useMemo( + () => (dragColumnName ? displayColumns.findIndex((c) => c.name === dragColumnName) : null), + [dragColumnName, displayColumns] + ) + + const handleSortAsc = useCallback( + (columnName: string) => handleSortChange(columnName, 'asc'), + [handleSortChange] + ) + + const handleSortDesc = useCallback( + (columnName: string) => handleSortChange(columnName, 'desc'), + [handleSortChange] + ) + const sortConfig = useMemo( () => ({ options: columnOptions, @@ -1619,7 +1823,12 @@ export function Table({
{!embedded && ( <> - + {filterOpen && ( )} @@ -1691,10 +1902,11 @@ export function Table({ checked={isAllRowsSelected} onCheckedChange={handleSelectAllToggle} /> - {displayColumns.map((column) => ( + {displayColumns.map((column, colIndex) => ( = selectedColumnRange.start && + colIndex <= selectedColumnRange.end + } + onColumnSelect={handleColumnSelect} + sortDirection={ + activeSortState?.column === column.name ? activeSortState.direction : null + } + onSortAsc={handleSortAsc} + onSortDesc={handleSortDesc} + onFilterColumn={handleFilterByColumn} /> ))} {userPermissions.canEdit && ( @@ -1744,6 +1965,7 @@ export function Table({ startPosition={prevPosition + 1} columns={displayColumns} normalizedSelection={normalizedSelection} + draggingColIndex={draggingColIndex} checkedRows={checkedRows} firstRowUnderHeader={prevPosition === -1} onCellMouseDown={handleCellMouseDown} @@ -1766,6 +1988,7 @@ export function Table({ : null } normalizedSelection={normalizedSelection} + draggingColIndex={draggingColIndex} onClick={handleCellClick} onDoubleClick={handleCellDoubleClick} onSave={handleInlineSave} @@ -1795,6 +2018,11 @@ export function Table({ style={{ left: dropIndicatorLeft }} /> )} +
{!isLoadingTable && !isLoadingRows && userPermissions.canEdit && ( @@ -1917,6 +2145,7 @@ interface PositionGapRowsProps { startPosition: number columns: ColumnDefinition[] normalizedSelection: NormalizedSelection | null + draggingColIndex: number | null checkedRows: Set firstRowUnderHeader?: boolean onCellMouseDown: (rowIndex: number, colIndex: number, shiftKey: boolean) => void @@ -1930,6 +2159,7 @@ const PositionGapRows = React.memo( startPosition, columns, normalizedSelection, + draggingColIndex, checkedRows, firstRowUnderHeader = false, onCellMouseDown, @@ -1995,7 +2225,11 @@ const PositionGapRows = React.memo( key={col.name} data-row={position} data-col={colIndex} - className={cn(CELL, (isHighlighted || isAnchor) && 'relative')} + className={cn( + CELL, + (isHighlighted || isAnchor) && 'relative', + draggingColIndex === colIndex && 'opacity-40' + )} onMouseDown={(e) => { if (e.button !== 0) return onCellMouseDown(position, colIndex, e.shiftKey) @@ -2040,6 +2274,7 @@ const PositionGapRows = React.memo( prev.startPosition !== next.startPosition || prev.columns !== next.columns || prev.normalizedSelection !== next.normalizedSelection || + prev.draggingColIndex !== next.draggingColIndex || prev.firstRowUnderHeader !== next.firstRowUnderHeader || prev.onCellMouseDown !== next.onCellMouseDown || prev.onCellMouseEnter !== next.onCellMouseEnter || @@ -2082,6 +2317,7 @@ interface DataRowProps { initialCharacter: string | null pendingCellValue: Record | null normalizedSelection: NormalizedSelection | null + draggingColIndex: number | null onClick: (rowId: string, columnName: string) => void onDoubleClick: (rowId: string, columnName: string) => void onSave: (rowId: string, columnName: string, value: unknown, reason: SaveReason) => void @@ -2132,6 +2368,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { prev.isFirstRow !== next.isFirstRow || prev.editingColumnName !== next.editingColumnName || prev.pendingCellValue !== next.pendingCellValue || + prev.draggingColIndex !== next.draggingColIndex || prev.onClick !== next.onClick || prev.onDoubleClick !== next.onDoubleClick || prev.onSave !== next.onSave || @@ -2168,6 +2405,7 @@ const DataRow = React.memo(function DataRow({ initialCharacter, pendingCellValue, normalizedSelection, + draggingColIndex, isRowChecked, onClick, onDoubleClick, @@ -2235,7 +2473,11 @@ const DataRow = React.memo(function DataRow({ key={column.name} data-row={rowIndex} data-col={colIndex} - className={cn(CELL, (isHighlighted || isAnchor || isEditing) && 'relative')} + className={cn( + CELL, + (isHighlighted || isAnchor || isEditing) && 'relative', + draggingColIndex === colIndex && 'opacity-40' + )} onMouseDown={(e) => { if (e.button !== 0 || isEditing) return onCellMouseDown(rowIndex, colIndex, e.shiftKey) @@ -2605,6 +2847,7 @@ const COLUMN_TYPE_OPTIONS: { type: string; label: string; icon: React.ElementTyp const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ column, + colIndex, readOnly, isRenaming, renameValue, @@ -2622,11 +2865,15 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onResizeEnd, isDragging, onDragStart, - onDragOver, - onDragEnd, - onDragLeave, + isColumnSelected, + onColumnSelect, + sortDirection, + onSortAsc, + onSortDesc, + onFilterColumn, }: { column: ColumnDefinition + colIndex: number readOnly?: boolean isRenaming: boolean renameValue: string @@ -2643,14 +2890,22 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onResize: (columnName: string, width: number) => void onResizeEnd: () => void isDragging?: boolean - onDragStart?: (columnName: string) => void - onDragOver?: (columnName: string, side: 'left' | 'right') => void - onDragEnd?: () => void - onDragLeave?: () => void + onDragStart?: ( + columnName: string, + element: HTMLElement, + pointerId: number, + startX: number + ) => void + isColumnSelected?: boolean + onColumnSelect?: (colIndex: number, shiftKey: boolean) => void + sortDirection?: SortDirection | null + onSortAsc?: (columnName: string) => void + onSortDesc?: (columnName: string) => void + onFilterColumn?: (columnName: string) => void }) { const renameInputRef = useRef(null) - useEffect(() => { + useLayoutEffect(() => { if (isRenaming && renameInputRef.current) { renameInputRef.current.focus() renameInputRef.current.select() @@ -2688,58 +2943,74 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ [column.name, onResizeStart, onResize, onResizeEnd] ) - const handleDragStart = useCallback( - (e: React.DragEvent) => { - if (readOnly || isRenaming) { - e.preventDefault() - return - } - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData('text/plain', column.name) - onDragStart?.(column.name) - }, - [column.name, readOnly, isRenaming, onDragStart] - ) + const [menuOpen, setMenuOpen] = useState(false) - const handleDragOver = useCallback( - (e: React.DragEvent) => { + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + if (readOnly || isRenaming) return e.preventDefault() - e.dataTransfer.dropEffect = 'move' - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() - const midX = rect.left + rect.width / 2 - const side = e.clientX < midX ? 'left' : 'right' - onDragOver?.(column.name, side) + onColumnSelect?.(colIndex, false) + setMenuOpen(true) }, - [column.name, onDragOver] + [readOnly, isRenaming, colIndex, onColumnSelect] ) - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault() - }, []) - - const handleDragEnd = useCallback(() => { - onDragEnd?.() - }, [onDragEnd]) + const handleThPointerDown = useCallback( + (e: React.PointerEvent) => { + if (isRenaming || e.button !== 0) return + e.preventDefault() - const handleDragLeave = useCallback( - (e: React.DragEvent) => { const th = e.currentTarget as HTMLElement - const related = e.relatedTarget as Node | null - if (related && th.contains(related)) return - onDragLeave?.() + const startX = e.clientX + const startY = e.clientY + const shiftKey = e.shiftKey + const pid = e.pointerId + th.setPointerCapture(pid) + let dragging = false + + const onMove = (ev: PointerEvent) => { + if (dragging) return + if (!readOnly && Math.hypot(ev.clientX - startX, ev.clientY - startY) > 4) { + dragging = true + th.removeEventListener('pointermove', onMove) + th.removeEventListener('pointerup', onUp) + th.removeEventListener('pointercancel', onCancel) + onDragStart?.(column.name, th, pid, ev.clientX) + } + } + + const onUp = () => { + th.removeEventListener('pointermove', onMove) + th.removeEventListener('pointerup', onUp) + th.removeEventListener('pointercancel', onCancel) + th.releasePointerCapture(pid) + if (!dragging) onColumnSelect?.(colIndex, shiftKey) + } + + const onCancel = () => { + th.removeEventListener('pointermove', onMove) + th.removeEventListener('pointerup', onUp) + th.removeEventListener('pointercancel', onCancel) + th.releasePointerCapture(pid) + } + + th.addEventListener('pointermove', onMove) + th.addEventListener('pointerup', onUp) + th.addEventListener('pointercancel', onCancel) }, - [onDragLeave] + [column.name, colIndex, isRenaming, readOnly, onColumnSelect, onDragStart] ) return ( {isRenaming ? (
@@ -2760,26 +3031,59 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ ) : readOnly ? (
- + {column.name} + {sortDirection && ( + + + + )}
) : ( -
- +
+ + + {column.name} + + {sortDirection && ( + + + + )} + + - +
- + e.preventDefault()} + > + onSortAsc?.(column.name)}> + + Sort ascending + + onSortDesc?.(column.name)}> + + Sort descending + + onFilterColumn?.(column.name)}> + + Filter by this column + + onRenameColumn(column.name)}> Rename column @@ -2823,20 +3127,11 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ -
- -
)} +
e.stopPropagation()} onPointerDown={handleResizePointerDown} /> @@ -2900,3 +3195,11 @@ function ColumnTypeIcon({ type }: { type: string }) { const Icon = COLUMN_TYPE_ICONS[type] ?? TypeText return } + +function SortDirectionIndicator({ direction }: { direction: SortDirection }) { + return direction === 'asc' ? ( + + ) : ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/export.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/export.test.ts new file mode 100644 index 0000000000..6cb09723fa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/export.test.ts @@ -0,0 +1,39 @@ +import { createTableColumn, createTableRow } from '@sim/testing' +import { describe, expect, it } from 'vitest' +import { buildTableCsv, formatTableExportValue } from './export' + +describe('table export utils', () => { + it('formats exported values using table display conventions', () => { + expect(formatTableExportValue('2026-04-03', { name: 'date', type: 'date' })).toBe('04/03/2026') + expect(formatTableExportValue({ nested: true }, { name: 'payload', type: 'json' })).toBe( + '{"nested":true}' + ) + expect(formatTableExportValue(null, { name: 'empty', type: 'string' })).toBe('') + }) + + it('builds CSV using visible columns and escaped values', () => { + const columns = [ + createTableColumn({ name: 'name', type: 'string' }), + createTableColumn({ name: 'date', type: 'date' }), + createTableColumn({ name: 'notes', type: 'json' }), + ] + + const rows = [ + createTableRow({ + id: 'row_1', + position: 0, + createdAt: '2026-04-03T00:00:00.000Z', + updatedAt: '2026-04-03T00:00:00.000Z', + data: { + name: 'Ada "Lovelace"', + date: '2026-04-03', + notes: { text: 'line 1\nline 2' }, + }, + }), + ] + + expect(buildTableCsv(columns, rows)).toBe( + 'name,date,notes\r\n"Ada ""Lovelace""",04/03/2026,"{""text"":""line 1\\nline 2""}"' + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/export.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/export.ts new file mode 100644 index 0000000000..0c5aae9094 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/export.ts @@ -0,0 +1,38 @@ +import type { ColumnDefinition, TableRow } from '@/lib/table' +import { storageToDisplay } from './utils' + +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +export function formatTableExportValue(value: unknown, column: ColumnDefinition): string { + if (value === null || value === undefined) return '' + + switch (column.type) { + case 'date': + return storageToDisplay(String(value)) + case 'json': + return typeof value === 'string' ? value : safeJsonStringify(value) + default: + return String(value) + } +} + +export function escapeCsvCell(value: string): string { + return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value +} + +export function buildTableCsv(columns: ColumnDefinition[], rows: TableRow[]): string { + const headerRow = columns.map((column) => escapeCsvCell(column.name)).join(',') + const dataRows = rows.map((row) => + columns + .map((column) => escapeCsvCell(formatTableExportValue(row.data[column.name], column))) + .join(',') + ) + + return [headerRow, ...dataRows].join('\r\n') +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/index.ts index 8bd7a4b4b4..492824c9b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/index.ts @@ -1,2 +1,3 @@ export * from './use-context-menu' +export * from './use-export-table' export * from './use-table-data' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-context-menu.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-context-menu.ts index 868f36f87a..d5376e8819 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-context-menu.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-context-menu.ts @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react' import type { TableRow } from '@/lib/table' -import type { ContextMenuState } from '../types' +import type { ContextMenuState } from '@/app/workspace/[workspaceId]/tables/[tableId]/types' interface UseContextMenuReturn { contextMenu: ContextMenuState diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-export-table.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-export-table.ts new file mode 100644 index 0000000000..8ddbae1a32 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-export-table.ts @@ -0,0 +1,84 @@ +'use client' + +import { useCallback, useState } from 'react' +import { usePostHog } from 'posthog-js/react' +import { toast } from '@/components/emcn' +import { downloadFile, sanitizePathSegment } from '@/lib/core/utils/file-download' +import { captureEvent } from '@/lib/posthog/client' +import type { ColumnDefinition } from '@/lib/table' +import { buildTableCsv } from '@/app/workspace/[workspaceId]/tables/[tableId]/export' +import type { QueryOptions } from '@/app/workspace/[workspaceId]/tables/[tableId]/types' +import { fetchAllTableRows } from '@/hooks/queries/tables' + +interface UseExportTableParams { + workspaceId: string + tableId: string + tableName?: string | null + columns: ColumnDefinition[] + queryOptions: QueryOptions + canExport: boolean +} + +export function useExportTable({ + workspaceId, + tableId, + tableName, + columns, + queryOptions, + canExport, +}: UseExportTableParams) { + const posthog = usePostHog() + const [isExporting, setIsExporting] = useState(false) + + const handleExportTable = useCallback(async () => { + if (!canExport || !workspaceId || !tableId || isExporting) { + return + } + + setIsExporting(true) + + try { + const { rows } = await fetchAllTableRows({ + workspaceId, + tableId, + filter: queryOptions.filter, + sort: queryOptions.sort, + }) + + const filename = `${sanitizePathSegment(tableName?.trim() || 'table')}.csv` + const csvContent = buildTableCsv(columns, rows) + + downloadFile(csvContent, filename, 'text/csv;charset=utf-8;') + + captureEvent(posthog, 'table_exported', { + workspace_id: workspaceId, + table_id: tableId, + row_count: rows.length, + column_count: columns.length, + has_filter: Boolean(queryOptions.filter), + has_sort: Boolean(queryOptions.sort), + }) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to export table', { + duration: 5000, + }) + } finally { + setIsExporting(false) + } + }, [ + canExport, + columns, + isExporting, + posthog, + queryOptions.filter, + queryOptions.sort, + tableId, + tableName, + workspaceId, + ]) + + return { + isExporting, + handleExportTable, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-data.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-data.ts index 992d1ca653..1be746955d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-data.ts @@ -1,6 +1,7 @@ import type { TableDefinition, TableRow } from '@/lib/table' +import { TABLE_LIMITS } from '@/lib/table/constants' +import type { QueryOptions } from '@/app/workspace/[workspaceId]/tables/[tableId]/types' import { useTable, useTableRows } from '@/hooks/queries/tables' -import type { QueryOptions } from '../types' interface UseTableDataParams { workspaceId: string @@ -30,7 +31,7 @@ export function useTableData({ } = useTableRows({ workspaceId, tableId, - limit: 1000, + limit: TABLE_LIMITS.MAX_QUERY_LIMIT, offset: 0, filter: queryOptions.filter, sort: queryOptions.sort, diff --git a/apps/sim/components/emcn/components/modal/modal.tsx b/apps/sim/components/emcn/components/modal/modal.tsx index f9afb95b6d..b682733c33 100644 --- a/apps/sim/components/emcn/components/modal/modal.tsx +++ b/apps/sim/components/emcn/components/modal/modal.tsx @@ -51,6 +51,13 @@ import { Button } from '../button/button' const ANIMATION_CLASSES = 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none' +/** + * Modal content animation classes. + * We keep only the slide animations (no zoom) to stabilize positioning while avoiding scale effects. + */ +const CONTENT_ANIMATION_CLASSES = + 'data-[state=closed]:slide-out-to-top-[50%] data-[state=open]:slide-in-from-top-[50%] motion-reduce:animate-none' + /** * Root modal component. Manages open state. */ @@ -159,8 +166,8 @@ const ModalContent = React.forwardRef< )} style={{ left: isWorkflowPage - ? // --panel-width is always the rendered panel width on /w/ routes (panel is never hidden/collapsed) - 'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)' +<<<<<<< HEAD + ? 'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)' : 'calc(var(--sidebar-width) / 2 + 50%)', ...style, }} diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index cef8e4447f..282a0608c5 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from '@/components/emcn' import type { Filter, RowData, Sort, TableDefinition, TableMetadata, TableRow } from '@/lib/table' +import { TABLE_LIMITS } from '@/lib/table/constants' const logger = createLogger('TableQueries') @@ -23,7 +24,7 @@ export const tableKeys = { [...tableKeys.rowsRoot(tableId), paramsKey] as const, } -interface TableRowsParams { +export interface TableRowsParams { workspaceId: string tableId: string limit: number @@ -32,7 +33,7 @@ interface TableRowsParams { sort?: Sort | null } -interface TableRowsResponse { +export interface TableRowsResponse { rows: TableRow[] totalCount: number } @@ -83,7 +84,7 @@ async function fetchTable( return (data as { table: TableDefinition }).table } -async function fetchTableRows({ +export async function fetchTableRows({ workspaceId, tableId, limit, @@ -125,6 +126,48 @@ async function fetchTableRows({ } } +export async function fetchAllTableRows({ + workspaceId, + tableId, + filter, + sort, + pageSize = TABLE_LIMITS.MAX_QUERY_LIMIT, + signal, +}: Pick & { + pageSize?: number + signal?: AbortSignal +}): Promise { + const rows: TableRow[] = [] + let totalCount = Number.POSITIVE_INFINITY + let offset = 0 + + while (rows.length < totalCount) { + const response = await fetchTableRows({ + workspaceId, + tableId, + limit: pageSize, + offset, + filter, + sort, + signal, + }) + + rows.push(...response.rows) + totalCount = response.totalCount + + if (response.rows.length === 0) { + break + } + + offset += response.rows.length + } + + return { + rows, + totalCount: Number.isFinite(totalCount) ? totalCount : rows.length, + } +} + function invalidateRowData(queryClient: ReturnType, tableId: string) { queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) }) } diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts index b17b7ca32b..de417f8d7b 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts @@ -1,30 +1,11 @@ /** * @vitest-environment node */ +import { createEditWorkflowRegistryMock } from '@sim/testing' import { describe, expect, it, vi } from 'vitest' import { createBlockFromParams } from './builders' -const agentBlockConfig = { - type: 'agent', - name: 'Agent', - outputs: { - content: { type: 'string', description: 'Default content output' }, - }, - subBlocks: [{ id: 'responseFormat', type: 'response-format' }], -} - -const conditionBlockConfig = { - type: 'condition', - name: 'Condition', - outputs: {}, - subBlocks: [{ id: 'conditions', type: 'condition-input' }], -} - -vi.mock('@/blocks/registry', () => ({ - getAllBlocks: () => [agentBlockConfig, conditionBlockConfig], - getBlock: (type: string) => - type === 'agent' ? agentBlockConfig : type === 'condition' ? conditionBlockConfig : undefined, -})) +vi.mock('@/blocks/registry', () => createEditWorkflowRegistryMock(['agent', 'condition'])) describe('createBlockFromParams', () => { it('derives agent outputs from responseFormat when outputs are not provided', () => { diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts index 8c184e0cf5..52d5fdd79f 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts @@ -1,69 +1,16 @@ /** * @vitest-environment node */ +import { createEditWorkflowRegistryMock } from '@sim/testing' +import { loggerMock } from '@sim/testing/mocks' import { describe, expect, it, vi } from 'vitest' import { applyOperationsToWorkflowState } from './engine' -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), -})) - -vi.mock('@/blocks/registry', () => ({ - getAllBlocks: () => [ - { - type: 'condition', - name: 'Condition', - subBlocks: [{ id: 'conditions', type: 'condition-input' }], - }, - { - type: 'agent', - name: 'Agent', - subBlocks: [ - { id: 'systemPrompt', type: 'long-input' }, - { id: 'model', type: 'combobox' }, - ], - }, - { - type: 'function', - name: 'Function', - subBlocks: [ - { id: 'code', type: 'code' }, - { id: 'language', type: 'dropdown' }, - ], - }, - ], - getBlock: (type: string) => { - const blocks: Record = { - condition: { - type: 'condition', - name: 'Condition', - subBlocks: [{ id: 'conditions', type: 'condition-input' }], - }, - agent: { - type: 'agent', - name: 'Agent', - subBlocks: [ - { id: 'systemPrompt', type: 'long-input' }, - { id: 'model', type: 'combobox' }, - ], - }, - function: { - type: 'function', - name: 'Function', - subBlocks: [ - { id: 'code', type: 'code' }, - { id: 'language', type: 'dropdown' }, - ], - }, - } - return blocks[type] || undefined - }, -})) +vi.mock('@sim/logger', () => loggerMock) + +vi.mock('@/blocks/registry', () => + createEditWorkflowRegistryMock(['condition', 'agent', 'function']) +) function makeLoopWorkflow() { return { diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index 050c019203..3abf8173ae 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -1,32 +1,12 @@ /** * @vitest-environment node */ +import { createEditWorkflowRegistryMock } from '@sim/testing' import { describe, expect, it, vi } from 'vitest' import { normalizeConditionRouterIds } from './builders' import { validateInputsForBlock } from './validation' -const conditionBlockConfig = { - type: 'condition', - name: 'Condition', - outputs: {}, - subBlocks: [{ id: 'conditions', type: 'condition-input' }], -} - -const routerBlockConfig = { - type: 'router_v2', - name: 'Router', - outputs: {}, - subBlocks: [{ id: 'routes', type: 'router-input' }], -} - -vi.mock('@/blocks/registry', () => ({ - getBlock: (type: string) => - type === 'condition' - ? conditionBlockConfig - : type === 'router_v2' - ? routerBlockConfig - : undefined, -})) +vi.mock('@/blocks/registry', () => createEditWorkflowRegistryMock(['condition', 'router_v2'])) describe('validateInputsForBlock', () => { it('accepts condition-input arrays with arbitrary item ids', () => { diff --git a/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts b/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts index 658febd7d6..43ad67aef8 100644 --- a/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts +++ b/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts @@ -1,11 +1,11 @@ -import { loggerMock } from '@sim/testing' +import { createFeatureFlagsMock, loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { RateLimiter } from './rate-limiter' import type { ConsumeResult, RateLimitStorageAdapter, TokenStatus } from './storage' import { MANUAL_EXECUTION_LIMIT, RATE_LIMITS, RateLimitError } from './types' vi.mock('@sim/logger', () => loggerMock) -vi.mock('@/lib/core/config/feature-flags', () => ({ isBillingEnabled: true })) +vi.mock('@/lib/core/config/feature-flags', () => createFeatureFlagsMock({ isBillingEnabled: true })) interface MockAdapter { consumeTokens: Mock diff --git a/apps/sim/lib/core/utils/file-download.ts b/apps/sim/lib/core/utils/file-download.ts new file mode 100644 index 0000000000..378e364821 --- /dev/null +++ b/apps/sim/lib/core/utils/file-download.ts @@ -0,0 +1,36 @@ +import { createLogger } from '@sim/logger' + +const logger = createLogger('FileDownload') + +/** + * Sanitizes a string for use as a file or path segment in exported assets. + */ +export function sanitizePathSegment(name: string): string { + return name.replace(/[^a-z0-9-_]/gi, '-') +} + +/** + * Downloads a file to the user's device. + * Throws if the browser cannot create or trigger the download. + */ +export function downloadFile( + content: Blob | string, + filename: string, + mimeType = 'application/json' +): void { + try { + const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } catch (error) { + logger.error('Failed to download file:', error) + throw error + } +} diff --git a/apps/sim/lib/mcp/connection-manager.test.ts b/apps/sim/lib/mcp/connection-manager.test.ts index 8b4b684cf0..62ea867b5f 100644 --- a/apps/sim/lib/mcp/connection-manager.test.ts +++ b/apps/sim/lib/mcp/connection-manager.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { loggerMock } from '@sim/testing' +import { createFeatureFlagsMock, loggerMock } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' interface MockMcpClient { @@ -38,7 +38,7 @@ const { MockMcpClientConstructor, mockOnToolsChanged, mockPublishToolsChanged } ) vi.mock('@sim/logger', () => loggerMock) -vi.mock('@/lib/core/config/feature-flags', () => ({ isTest: false })) +vi.mock('@/lib/core/config/feature-flags', () => createFeatureFlagsMock({ isTest: false })) vi.mock('@/lib/mcp/pubsub', () => ({ mcpPubSub: { onToolsChanged: mockOnToolsChanged, diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index c186d82cd4..11c19efa57 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -317,6 +317,15 @@ export interface PostHogEventMap { workspace_id: string } + table_exported: { + workspace_id: string + table_id: string + row_count: number + column_count: number + has_filter: boolean + has_sort: boolean + } + custom_tool_saved: { tool_id: string workspace_id: string diff --git a/apps/sim/lib/table/__tests__/validation.test.ts b/apps/sim/lib/table/__tests__/validation.test.ts index 557354bf57..66220fed69 100644 --- a/apps/sim/lib/table/__tests__/validation.test.ts +++ b/apps/sim/lib/table/__tests__/validation.test.ts @@ -1,10 +1,10 @@ /** * @vitest-environment node */ +import { createTableColumn } from '@sim/testing' import { describe, expect, it } from 'vitest' import { TABLE_LIMITS } from '../constants' import { - type ColumnDefinition, getUniqueColumns, type TableSchema, validateColumnDefinition, @@ -66,12 +66,12 @@ describe('Validation', () => { describe('validateColumnDefinition', () => { it('should accept valid column definition', () => { - const column: ColumnDefinition = { + const column = createTableColumn({ name: 'email', type: 'string', required: true, unique: true, - } + }) const result = validateColumnDefinition(column) expect(result.valid).toBe(true) }) @@ -80,19 +80,20 @@ describe('Validation', () => { const types = ['string', 'number', 'boolean', 'date', 'json'] as const for (const type of types) { - const result = validateColumnDefinition({ name: 'test', type }) + const result = validateColumnDefinition(createTableColumn({ name: 'test', type })) expect(result.valid).toBe(true) } }) it('should reject empty column name', () => { - const result = validateColumnDefinition({ name: '', type: 'string' }) + const result = validateColumnDefinition(createTableColumn({ name: '', type: 'string' })) expect(result.valid).toBe(false) expect(result.errors).toContain('Column name is required') }) it('should reject invalid column type', () => { const result = validateColumnDefinition({ + ...createTableColumn({ name: 'test' }), name: 'test', type: 'invalid' as any, }) @@ -102,7 +103,7 @@ describe('Validation', () => { it('should reject column name exceeding max length', () => { const longName = 'a'.repeat(TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH + 1) - const result = validateColumnDefinition({ name: longName, type: 'string' }) + const result = validateColumnDefinition(createTableColumn({ name: longName, type: 'string' })) expect(result.valid).toBe(false) expect(result.errors[0]).toContain('exceeds maximum length') }) @@ -112,9 +113,9 @@ describe('Validation', () => { it('should accept valid schema', () => { const schema: TableSchema = { columns: [ - { name: 'id', type: 'string', required: true, unique: true }, - { name: 'name', type: 'string', required: true }, - { name: 'age', type: 'number' }, + createTableColumn({ name: 'id', type: 'string', required: true, unique: true }), + createTableColumn({ name: 'name', type: 'string', required: true }), + createTableColumn({ name: 'age', type: 'number' }), ], } const result = validateTableSchema(schema) @@ -131,8 +132,8 @@ describe('Validation', () => { it('should reject duplicate column names', () => { const schema: TableSchema = { columns: [ - { name: 'id', type: 'string' }, - { name: 'ID', type: 'number' }, + createTableColumn({ name: 'id', type: 'string' }), + createTableColumn({ name: 'ID', type: 'number' }), ], } const result = validateTableSchema(schema) @@ -153,10 +154,9 @@ describe('Validation', () => { }) it('should reject schema exceeding max columns', () => { - const columns = Array.from({ length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE + 1 }, (_, i) => ({ - name: `col_${i}`, - type: 'string' as const, - })) + const columns = Array.from({ length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE + 1 }, (_, i) => + createTableColumn({ name: `col_${i}`, type: 'string' }) + ) const result = validateTableSchema({ columns }) expect(result.valid).toBe(false) expect(result.errors[0]).toContain('exceeds maximum columns') @@ -182,11 +182,11 @@ describe('Validation', () => { describe('validateRowAgainstSchema', () => { const schema: TableSchema = { columns: [ - { name: 'name', type: 'string', required: true }, - { name: 'age', type: 'number' }, - { name: 'active', type: 'boolean' }, - { name: 'created', type: 'date' }, - { name: 'metadata', type: 'json' }, + createTableColumn({ name: 'name', type: 'string', required: true }), + createTableColumn({ name: 'age', type: 'number' }), + createTableColumn({ name: 'active', type: 'boolean' }), + createTableColumn({ name: 'created', type: 'date' }), + createTableColumn({ name: 'metadata', type: 'json' }), ], } @@ -281,10 +281,10 @@ describe('Validation', () => { it('should return only columns with unique=true', () => { const schema: TableSchema = { columns: [ - { name: 'id', type: 'string', unique: true }, - { name: 'email', type: 'string', unique: true }, - { name: 'name', type: 'string' }, - { name: 'count', type: 'number', unique: false }, + createTableColumn({ name: 'id', type: 'string', unique: true }), + createTableColumn({ name: 'email', type: 'string', unique: true }), + createTableColumn({ name: 'name', type: 'string' }), + createTableColumn({ name: 'count', type: 'number', unique: false }), ], } const result = getUniqueColumns(schema) @@ -295,8 +295,8 @@ describe('Validation', () => { it('should return empty array when no unique columns', () => { const schema: TableSchema = { columns: [ - { name: 'name', type: 'string' }, - { name: 'value', type: 'number' }, + createTableColumn({ name: 'name', type: 'string' }), + createTableColumn({ name: 'value', type: 'number' }), ], } const result = getUniqueColumns(schema) @@ -307,9 +307,9 @@ describe('Validation', () => { describe('validateUniqueConstraints', () => { const schema: TableSchema = { columns: [ - { name: 'id', type: 'string', unique: true }, - { name: 'email', type: 'string', unique: true }, - { name: 'name', type: 'string' }, + createTableColumn({ name: 'id', type: 'string', unique: true }), + createTableColumn({ name: 'email', type: 'string', unique: true }), + createTableColumn({ name: 'name', type: 'string' }), ], } diff --git a/apps/sim/lib/workflows/comparison/compare.test.ts b/apps/sim/lib/workflows/comparison/compare.test.ts index f3a139ac91..92a142df30 100644 --- a/apps/sim/lib/workflows/comparison/compare.test.ts +++ b/apps/sim/lib/workflows/comparison/compare.test.ts @@ -4,6 +4,7 @@ import { createBlock as createTestBlock, createWorkflowState as createTestWorkflowState, + createWorkflowVariablesMap, } from '@sim/testing' import { describe, expect, it } from 'vitest' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -46,6 +47,10 @@ function createBlock(id: string, overrides: Record = {}): any { }) } +function createVariablesMap(...variables: Parameters[0]): any { + return createWorkflowVariablesMap(variables) +} + describe('hasWorkflowChanged', () => { describe('Basic Cases', () => { it.concurrent('should return true when deployedState is null', () => { @@ -2181,9 +2186,12 @@ describe('hasWorkflowChanged', () => { const currentState = { ...createWorkflowState({}), - variables: { - var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' }, - }, + variables: createVariablesMap({ + id: 'var1', + name: 'myVar', + type: 'string', + value: 'hello', + }), } expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true) @@ -2192,9 +2200,12 @@ describe('hasWorkflowChanged', () => { it.concurrent('should detect removed variables', () => { const deployedState = { ...createWorkflowState({}), - variables: { - var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' }, - }, + variables: createVariablesMap({ + id: 'var1', + name: 'myVar', + type: 'string', + value: 'hello', + }), } const currentState = { @@ -2208,16 +2219,22 @@ describe('hasWorkflowChanged', () => { it.concurrent('should detect variable value changes', () => { const deployedState = { ...createWorkflowState({}), - variables: { - var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' }, - }, + variables: createVariablesMap({ + id: 'var1', + name: 'myVar', + type: 'string', + value: 'hello', + }), } const currentState = { ...createWorkflowState({}), - variables: { - var1: { id: 'var1', name: 'myVar', type: 'string', value: 'world' }, - }, + variables: createVariablesMap({ + id: 'var1', + name: 'myVar', + type: 'string', + value: 'world', + }), } expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true) @@ -2226,16 +2243,12 @@ describe('hasWorkflowChanged', () => { it.concurrent('should detect variable type changes', () => { const deployedState = { ...createWorkflowState({}), - variables: { - var1: { id: 'var1', name: 'myVar', type: 'string', value: '123' }, - }, + variables: createVariablesMap({ id: 'var1', name: 'myVar', type: 'string', value: '123' }), } const currentState = { ...createWorkflowState({}), - variables: { - var1: { id: 'var1', name: 'myVar', type: 'number', value: 123 }, - }, + variables: createVariablesMap({ id: 'var1', name: 'myVar', type: 'number', value: 123 }), } expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true) @@ -2244,16 +2257,22 @@ describe('hasWorkflowChanged', () => { it.concurrent('should detect variable name changes', () => { const deployedState = { ...createWorkflowState({}), - variables: { - var1: { id: 'var1', name: 'oldName', type: 'string', value: 'hello' }, - }, + variables: createVariablesMap({ + id: 'var1', + name: 'oldName', + type: 'string', + value: 'hello', + }), } const currentState = { ...createWorkflowState({}), - variables: { - var1: { id: 'var1', name: 'newName', type: 'string', value: 'hello' }, - }, + variables: createVariablesMap({ + id: 'var1', + name: 'newName', + type: 'string', + value: 'hello', + }), } expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true) @@ -2262,18 +2281,18 @@ describe('hasWorkflowChanged', () => { it.concurrent('should not detect change for identical variables', () => { const deployedState = { ...createWorkflowState({}), - variables: { - var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' }, - var2: { id: 'var2', name: 'count', type: 'number', value: 42 }, - }, + variables: createVariablesMap( + { id: 'var1', name: 'myVar', type: 'string', value: 'hello' }, + { id: 'var2', name: 'count', type: 'number', value: 42 } + ), } const currentState = { ...createWorkflowState({}), - variables: { - var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' }, - var2: { id: 'var2', name: 'count', type: 'number', value: 42 }, - }, + variables: createVariablesMap( + { id: 'var1', name: 'myVar', type: 'string', value: 'hello' }, + { id: 'var2', name: 'count', type: 'number', value: 42 } + ), } expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(false) @@ -2310,16 +2329,22 @@ describe('hasWorkflowChanged', () => { it.concurrent('should handle complex variable values (objects)', () => { const deployedState = { ...createWorkflowState({}), - variables: { - var1: { id: 'var1', name: 'config', type: 'object', value: { key: 'value1' } }, - }, + variables: createVariablesMap({ + id: 'var1', + name: 'config', + type: 'object', + value: { key: 'value1' }, + }), } const currentState = { ...createWorkflowState({}), - variables: { - var1: { id: 'var1', name: 'config', type: 'object', value: { key: 'value2' } }, - }, + variables: createVariablesMap({ + id: 'var1', + name: 'config', + type: 'object', + value: { key: 'value2' }, + }), } expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true) @@ -2328,16 +2353,22 @@ describe('hasWorkflowChanged', () => { it.concurrent('should handle complex variable values (arrays)', () => { const deployedState = { ...createWorkflowState({}), - variables: { - var1: { id: 'var1', name: 'items', type: 'array', value: [1, 2, 3] }, - }, + variables: createVariablesMap({ + id: 'var1', + name: 'items', + type: 'array', + value: [1, 2, 3], + }), } const currentState = { ...createWorkflowState({}), - variables: { - var1: { id: 'var1', name: 'items', type: 'array', value: [1, 2, 4] }, - }, + variables: createVariablesMap({ + id: 'var1', + name: 'items', + type: 'array', + value: [1, 2, 4], + }), } expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true) @@ -2346,18 +2377,18 @@ describe('hasWorkflowChanged', () => { it.concurrent('should not detect change when variable key order differs', () => { const deployedState = { ...createWorkflowState({}), - variables: { - var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' }, - var2: { id: 'var2', name: 'count', type: 'number', value: 42 }, - }, + variables: createVariablesMap( + { id: 'var1', name: 'myVar', type: 'string', value: 'hello' }, + { id: 'var2', name: 'count', type: 'number', value: 42 } + ), } const currentState = { ...createWorkflowState({}), - variables: { - var2: { id: 'var2', name: 'count', type: 'number', value: 42 }, - var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' }, - }, + variables: createVariablesMap( + { id: 'var2', name: 'count', type: 'number', value: 42 }, + { id: 'var1', name: 'myVar', type: 'string', value: 'hello' } + ), } expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(false) @@ -2844,31 +2875,27 @@ describe('hasWorkflowChanged', () => { block1: createBlock('block1'), }, }) - ;(deployedState as any).variables = { - var1: { - id: 'var1', - workflowId: 'workflow1', - name: 'myVar', - type: 'plain', - value: 'test', - }, - } + ;(deployedState as any).variables = createVariablesMap({ + id: 'var1', + workflowId: 'workflow1', + name: 'myVar', + type: 'plain', + value: 'test', + }) const currentState = createWorkflowState({ blocks: { block1: createBlock('block1'), }, }) - ;(currentState as any).variables = { - var1: { - id: 'var1', - workflowId: 'workflow1', - name: 'myVar', - type: 'plain', - value: 'test', - validationError: undefined, - }, - } + ;(currentState as any).variables = createVariablesMap({ + id: 'var1', + workflowId: 'workflow1', + name: 'myVar', + type: 'plain', + value: 'test', + validationError: undefined, + }) expect(hasWorkflowChanged(currentState, deployedState)).toBe(false) }) @@ -2879,31 +2906,27 @@ describe('hasWorkflowChanged', () => { block1: createBlock('block1'), }, }) - ;(deployedState as any).variables = { - var1: { - id: 'var1', - workflowId: 'workflow1', - name: 'myVar', - type: 'number', - value: 'invalid', - }, - } + ;(deployedState as any).variables = createVariablesMap({ + id: 'var1', + workflowId: 'workflow1', + name: 'myVar', + type: 'number', + value: 'invalid', + }) const currentState = createWorkflowState({ blocks: { block1: createBlock('block1'), }, }) - ;(currentState as any).variables = { - var1: { - id: 'var1', - workflowId: 'workflow1', - name: 'myVar', - type: 'number', - value: 'invalid', - validationError: 'Not a valid number', - }, - } + ;(currentState as any).variables = createVariablesMap({ + id: 'var1', + workflowId: 'workflow1', + name: 'myVar', + type: 'number', + value: 'invalid', + validationError: 'Not a valid number', + }) expect(hasWorkflowChanged(currentState, deployedState)).toBe(false) }) @@ -2914,31 +2937,27 @@ describe('hasWorkflowChanged', () => { block1: createBlock('block1'), }, }) - ;(deployedState as any).variables = { - var1: { - id: 'var1', - workflowId: 'workflow1', - name: 'myVar', - type: 'plain', - value: 'old value', - }, - } + ;(deployedState as any).variables = createVariablesMap({ + id: 'var1', + workflowId: 'workflow1', + name: 'myVar', + type: 'plain', + value: 'old value', + }) const currentState = createWorkflowState({ blocks: { block1: createBlock('block1'), }, }) - ;(currentState as any).variables = { - var1: { - id: 'var1', - workflowId: 'workflow1', - name: 'myVar', - type: 'plain', - value: 'new value', - validationError: undefined, - }, - } + ;(currentState as any).variables = createVariablesMap({ + id: 'var1', + workflowId: 'workflow1', + name: 'myVar', + type: 'plain', + value: 'new value', + validationError: undefined, + }) expect(hasWorkflowChanged(currentState, deployedState)).toBe(true) }) @@ -2956,15 +2975,13 @@ describe('hasWorkflowChanged', () => { block1: createBlock('block1'), }, }) - ;(currentState as any).variables = { - var1: { - id: 'var1', - workflowId: 'workflow1', - name: 'myVar', - type: 'plain', - value: 'test', - }, - } + ;(currentState as any).variables = createVariablesMap({ + id: 'var1', + workflowId: 'workflow1', + name: 'myVar', + type: 'plain', + value: 'test', + }) expect(hasWorkflowChanged(currentState, deployedState)).toBe(true) }) @@ -2975,15 +2992,13 @@ describe('hasWorkflowChanged', () => { block1: createBlock('block1'), }, }) - ;(deployedState as any).variables = { - var1: { - id: 'var1', - workflowId: 'workflow1', - name: 'myVar', - type: 'plain', - value: 'test', - }, - } + ;(deployedState as any).variables = createVariablesMap({ + id: 'var1', + workflowId: 'workflow1', + name: 'myVar', + type: 'plain', + value: 'test', + }) const currentState = createWorkflowState({ blocks: { @@ -3151,7 +3166,7 @@ describe('generateWorkflowDiffSummary', () => { }) const currentState = createWorkflowState({ blocks: { block1: createBlock('block1') }, - variables: { var1: { id: 'var1', name: 'test', type: 'string', value: 'hello' } }, + variables: createVariablesMap({ id: 'var1', name: 'test', type: 'string', value: 'hello' }), }) const result = generateWorkflowDiffSummary(currentState, previousState) expect(result.hasChanges).toBe(true) @@ -3161,11 +3176,11 @@ describe('generateWorkflowDiffSummary', () => { it.concurrent('should detect modified variables', () => { const previousState = createWorkflowState({ blocks: { block1: createBlock('block1') }, - variables: { var1: { id: 'var1', name: 'test', type: 'string', value: 'hello' } }, + variables: createVariablesMap({ id: 'var1', name: 'test', type: 'string', value: 'hello' }), }) const currentState = createWorkflowState({ blocks: { block1: createBlock('block1') }, - variables: { var1: { id: 'var1', name: 'test', type: 'string', value: 'world' } }, + variables: createVariablesMap({ id: 'var1', name: 'test', type: 'string', value: 'world' }), }) const result = generateWorkflowDiffSummary(currentState, previousState) expect(result.hasChanges).toBe(true) diff --git a/apps/sim/lib/workflows/lifecycle.test.ts b/apps/sim/lib/workflows/lifecycle.test.ts index 473ff68a3c..bedf723bb5 100644 --- a/apps/sim/lib/workflows/lifecycle.test.ts +++ b/apps/sim/lib/workflows/lifecycle.test.ts @@ -1,6 +1,8 @@ /** * @vitest-environment node */ +import { createMockSelectChain, createMockUpdateChain } from '@sim/testing' +import { loggerMock } from '@sim/testing/mocks' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -35,13 +37,7 @@ vi.mock('@sim/db/schema', () => ({ workflowSchedule: { archivedAt: 'workflow_schedule_archived_at' }, })) -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), -})) +vi.mock('@sim/logger', () => loggerMock) vi.mock('@/lib/workflows/utils', () => ({ getWorkflowById: (...args: unknown[]) => mockGetWorkflowById(...args), @@ -66,24 +62,6 @@ vi.mock('@/lib/core/telemetry', () => ({ import { archiveWorkflow } from '@/lib/workflows/lifecycle' -function createSelectChain(result: T) { - const chain = { - from: vi.fn().mockReturnThis(), - innerJoin: vi.fn().mockReturnThis(), - where: vi.fn().mockResolvedValue(result), - } - - return chain -} - -function createUpdateChain() { - return { - set: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - } -} - describe('workflow lifecycle', () => { beforeEach(() => { vi.clearAllMocks() @@ -107,10 +85,10 @@ describe('workflow lifecycle', () => { archivedAt: new Date(), }) - mockSelect.mockReturnValue(createSelectChain([])) + mockSelect.mockReturnValue(createMockSelectChain([])) const tx = { - update: vi.fn().mockImplementation(() => createUpdateChain()), + update: vi.fn().mockImplementation(() => createMockUpdateChain()), } mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise) => callback(tx) diff --git a/apps/sim/lib/workflows/operations/import-export.ts b/apps/sim/lib/workflows/operations/import-export.ts index 164e9ff89a..fd7f35d77b 100644 --- a/apps/sim/lib/workflows/operations/import-export.ts +++ b/apps/sim/lib/workflows/operations/import-export.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { sanitizePathSegment } from '@/lib/core/utils/file-download' import { type ExportWorkflowState, sanitizeForExport, @@ -8,6 +9,8 @@ import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowImportExport') +export { downloadFile, sanitizePathSegment } from '@/lib/core/utils/file-download' + async function getJSZip() { const { default: JSZip } = await import('jszip') return JSZip @@ -43,36 +46,6 @@ export interface WorkspaceExportStructure { folders: FolderExportData[] } -/** - * Sanitizes a string for use as a path segment in a ZIP file. - */ -export function sanitizePathSegment(name: string): string { - return name.replace(/[^a-z0-9-_]/gi, '-') -} - -/** - * Downloads a file to the user's device. - */ -export function downloadFile( - content: Blob | string, - filename: string, - mimeType = 'application/json' -): void { - try { - const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) - } catch (error) { - logger.error('Failed to download file:', error) - } -} - /** * Fetches a workflow's state and variables for export. * Returns null if the workflow cannot be fetched. diff --git a/apps/sim/lib/workspaces/lifecycle.test.ts b/apps/sim/lib/workspaces/lifecycle.test.ts index cdc6c2fc61..a011d6ca93 100644 --- a/apps/sim/lib/workspaces/lifecycle.test.ts +++ b/apps/sim/lib/workspaces/lifecycle.test.ts @@ -1,6 +1,8 @@ /** * @vitest-environment node */ +import { createMockDeleteChain, createMockSelectChain, createMockUpdateChain } from '@sim/testing' +import { loggerMock } from '@sim/testing/mocks' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockSelect, mockTransaction, mockArchiveWorkflowsForWorkspace, mockGetWorkspaceWithOwner } = @@ -33,13 +35,7 @@ vi.mock('@sim/db/schema', () => ({ workspaceNotificationSubscription: { active: 'workspace_notification_active' }, })) -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), -})) +vi.mock('@sim/logger', () => loggerMock) vi.mock('@/lib/workflows/lifecycle', () => ({ archiveWorkflowsForWorkspace: (...args: unknown[]) => mockArchiveWorkflowsForWorkspace(...args), @@ -51,14 +47,6 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { archiveWorkspace } from './lifecycle' -function createUpdateChain() { - return { - set: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - } -} - describe('workspace lifecycle', () => { beforeEach(() => { vi.clearAllMocks() @@ -72,22 +60,12 @@ describe('workspace lifecycle', () => { archivedAt: null, }) mockArchiveWorkflowsForWorkspace.mockResolvedValue(2) - mockSelect.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ id: 'server-1' }]), - }), - }) + mockSelect.mockReturnValue(createMockSelectChain([{ id: 'server-1' }])) const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ id: 'kb-1' }]), - }), - }), - update: vi.fn().mockImplementation(() => createUpdateChain()), - delete: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockResolvedValue([]), - })), + select: vi.fn().mockReturnValue(createMockSelectChain([{ id: 'kb-1' }])), + update: vi.fn().mockImplementation(() => createMockUpdateChain()), + delete: vi.fn().mockImplementation(() => createMockDeleteChain()), } mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise) => callback(tx) diff --git a/packages/testing/src/factories/index.ts b/packages/testing/src/factories/index.ts index 586f7fea59..75ab958949 100644 --- a/packages/testing/src/factories/index.ts +++ b/packages/testing/src/factories/index.ts @@ -118,6 +118,15 @@ export { type SerializedConnection, type SerializedWorkflow, } from './serialized-block.factory' +export { + createTableColumn, + createTableRow, + type TableColumnFactoryOptions, + type TableColumnFixture, + type TableColumnType, + type TableRowFactoryOptions, + type TableRowFixture, +} from './table.factory' // Tool mock responses export { mockDriveResponses, @@ -178,3 +187,10 @@ export { type WorkflowFactoryOptions, type WorkflowStateFixture, } from './workflow.factory' +export { + createWorkflowVariable, + createWorkflowVariablesMap, + type WorkflowVariableFactoryOptions, + type WorkflowVariableFixture, + type WorkflowVariableType, +} from './workflow-variable.factory' diff --git a/packages/testing/src/factories/table.factory.test.ts b/packages/testing/src/factories/table.factory.test.ts new file mode 100644 index 0000000000..97eeb78b8e --- /dev/null +++ b/packages/testing/src/factories/table.factory.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { createTableColumn } from './table.factory' + +describe('table factory', () => { + it('generates default column names that match table naming rules', () => { + const generatedNames = Array.from({ length: 100 }, () => createTableColumn().name) + + for (const name of generatedNames) { + expect(name).toMatch(/^[a-z_][a-z0-9_]*$/) + } + }) +}) diff --git a/packages/testing/src/factories/table.factory.ts b/packages/testing/src/factories/table.factory.ts new file mode 100644 index 0000000000..e6102d94c4 --- /dev/null +++ b/packages/testing/src/factories/table.factory.ts @@ -0,0 +1,62 @@ +import { customAlphabet, nanoid } from 'nanoid' + +export type TableColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json' + +export interface TableColumnFixture { + name: string + type: TableColumnType + required?: boolean + unique?: boolean +} + +export interface TableRowFixture { + id: string + data: Record + position: number + createdAt: string + updatedAt: string +} + +export interface TableColumnFactoryOptions { + name?: string + type?: TableColumnType + required?: boolean + unique?: boolean +} + +export interface TableRowFactoryOptions { + id?: string + data?: Record + position?: number + createdAt?: string + updatedAt?: string +} + +const createTableColumnSuffix = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789_', 6) + +/** + * Creates a table column fixture with sensible defaults. + */ +export function createTableColumn(options: TableColumnFactoryOptions = {}): TableColumnFixture { + return { + name: options.name ?? `column_${createTableColumnSuffix()}`, + type: options.type ?? 'string', + required: options.required, + unique: options.unique, + } +} + +/** + * Creates a table row fixture with sensible defaults. + */ +export function createTableRow(options: TableRowFactoryOptions = {}): TableRowFixture { + const timestamp = new Date().toISOString() + + return { + id: options.id ?? `row_${nanoid(8)}`, + data: options.data ?? {}, + position: options.position ?? 0, + createdAt: options.createdAt ?? timestamp, + updatedAt: options.updatedAt ?? timestamp, + } +} diff --git a/packages/testing/src/factories/workflow-variable.factory.ts b/packages/testing/src/factories/workflow-variable.factory.ts new file mode 100644 index 0000000000..70ee0852a5 --- /dev/null +++ b/packages/testing/src/factories/workflow-variable.factory.ts @@ -0,0 +1,53 @@ +import { nanoid } from 'nanoid' + +export type WorkflowVariableType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' + +export interface WorkflowVariableFixture { + id: string + name: string + type: WorkflowVariableType + value: unknown + workflowId?: string + validationError?: string +} + +export interface WorkflowVariableFactoryOptions { + id?: string + name?: string + type?: WorkflowVariableType + value?: unknown + workflowId?: string + validationError?: string +} + +/** + * Creates a workflow variable fixture with sensible defaults. + */ +export function createWorkflowVariable( + options: WorkflowVariableFactoryOptions = {} +): WorkflowVariableFixture { + const id = options.id ?? `var_${nanoid(8)}` + + return { + id, + name: options.name ?? `variable_${id.slice(0, 4)}`, + type: options.type ?? 'string', + value: options.value ?? '', + workflowId: options.workflowId, + validationError: options.validationError, + } +} + +/** + * Creates a variables map keyed by variable id. + */ +export function createWorkflowVariablesMap( + variables: WorkflowVariableFactoryOptions[] = [] +): Record { + return Object.fromEntries( + variables.map((variable) => { + const fixture = createWorkflowVariable(variable) + return [fixture.id, fixture] + }) + ) +} diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index ce84686a15..db636bfe90 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -46,10 +46,14 @@ export * from './builders' export * from './factories' export { AuthTypeMock, + asyncRouteParams, auditMock, clearRedisMocks, + createEditWorkflowRegistryMock, createEnvMock, + createFeatureFlagsMock, createMockDb, + createMockDeleteChain, createMockFetch, createMockFormDataRequest, createMockGetEnv, @@ -57,15 +61,19 @@ export { createMockRedis, createMockRequest, createMockResponse, + createMockSelectChain, createMockSocket, createMockStorage, + createMockUpdateChain, databaseMock, defaultMockEnv, defaultMockUser, drizzleOrmMock, envMock, + featureFlagsMock, loggerMock, type MockAuthResult, + type MockFeatureFlags, type MockFetchResponse, type MockHybridAuthResult, type MockRedis, diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index f2ccd9348e..b39758d171 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -103,6 +103,38 @@ export function createMockDb() { } } +/** + * Creates a select chain that resolves from `where()`. + */ +export function createMockSelectChain(result: T) { + return { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + leftJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(result), + } +} + +/** + * Creates an update chain that resolves from `where()`. + */ +export function createMockUpdateChain(result: T = [] as T) { + return { + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(result), + }), + } +} + +/** + * Creates a delete chain that resolves from `where()`. + */ +export function createMockDeleteChain(result: T = [] as T) { + return { + where: vi.fn().mockResolvedValue(result), + } +} + /** * Mock module for @sim/db. * Use with vi.mock() to replace the real database. diff --git a/packages/testing/src/mocks/edit-workflow.mock.ts b/packages/testing/src/mocks/edit-workflow.mock.ts new file mode 100644 index 0000000000..cd0da8d98b --- /dev/null +++ b/packages/testing/src/mocks/edit-workflow.mock.ts @@ -0,0 +1,47 @@ +const editWorkflowBlockConfigs: Record = { + condition: { + type: 'condition', + name: 'Condition', + outputs: {}, + subBlocks: [{ id: 'conditions', type: 'condition-input' }], + }, + agent: { + type: 'agent', + name: 'Agent', + outputs: { + content: { type: 'string', description: 'Default content output' }, + }, + subBlocks: [ + { id: 'systemPrompt', type: 'long-input' }, + { id: 'model', type: 'combobox' }, + { id: 'responseFormat', type: 'response-format' }, + ], + }, + function: { + type: 'function', + name: 'Function', + outputs: {}, + subBlocks: [ + { id: 'code', type: 'code' }, + { id: 'language', type: 'dropdown' }, + ], + }, + router_v2: { + type: 'router_v2', + name: 'Router', + outputs: {}, + subBlocks: [{ id: 'routes', type: 'router-input' }], + }, +} + +export function createEditWorkflowRegistryMock(types?: string[]) { + const enabledTypes = new Set(types ?? Object.keys(editWorkflowBlockConfigs)) + const blocks = Object.fromEntries( + Object.entries(editWorkflowBlockConfigs).filter(([type]) => enabledTypes.has(type)) + ) + + return { + getAllBlocks: () => Object.values(blocks), + getBlock: (type: string) => blocks[type], + } +} diff --git a/packages/testing/src/mocks/feature-flags.mock.ts b/packages/testing/src/mocks/feature-flags.mock.ts new file mode 100644 index 0000000000..677bcb33b6 --- /dev/null +++ b/packages/testing/src/mocks/feature-flags.mock.ts @@ -0,0 +1,65 @@ +export interface MockFeatureFlags { + isProd: boolean + isDev: boolean + isTest: boolean + isHosted: boolean + isBillingEnabled: boolean + isEmailVerificationEnabled: boolean + isAuthDisabled: boolean + isRegistrationDisabled: boolean + isEmailPasswordEnabled: boolean + isSignupEmailValidationEnabled: boolean + isTriggerDevEnabled: boolean + isSsoEnabled: boolean + isCredentialSetsEnabled: boolean + isAccessControlEnabled: boolean + isOrganizationsEnabled: boolean + isInboxEnabled: boolean + isE2bEnabled: boolean + isAzureConfigured: boolean + isInvitationsDisabled: boolean + isPublicApiDisabled: boolean + isReactGrabEnabled: boolean + isReactScanEnabled: boolean + getAllowedIntegrationsFromEnv: () => string[] | null + getAllowedMcpDomainsFromEnv: () => string[] | null + getCostMultiplier: () => number +} + +/** + * Creates a mutable mock for the feature flags module. + */ +export function createFeatureFlagsMock( + overrides: Partial = {} +): MockFeatureFlags { + return { + isProd: false, + isDev: false, + isTest: true, + isHosted: false, + isBillingEnabled: false, + isEmailVerificationEnabled: false, + isAuthDisabled: false, + isRegistrationDisabled: false, + isEmailPasswordEnabled: true, + isSignupEmailValidationEnabled: false, + isTriggerDevEnabled: false, + isSsoEnabled: false, + isCredentialSetsEnabled: false, + isAccessControlEnabled: false, + isOrganizationsEnabled: false, + isInboxEnabled: false, + isE2bEnabled: false, + isAzureConfigured: false, + isInvitationsDisabled: false, + isPublicApiDisabled: false, + isReactGrabEnabled: false, + isReactScanEnabled: false, + getAllowedIntegrationsFromEnv: () => null, + getAllowedMcpDomainsFromEnv: () => null, + getCostMultiplier: () => 1, + ...overrides, + } +} + +export const featureFlagsMock = createFeatureFlagsMock() diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index 69a2e5984d..f4b3ba259e 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -16,7 +16,6 @@ * ``` */ -// API mocks export { mockCommonSchemas, mockConsoleLogger, @@ -24,16 +23,13 @@ export { mockKnowledgeSchemas, setupCommonApiMocks, } from './api.mock' -// Audit mocks export { auditMock } from './audit.mock' -// Auth mocks export { defaultMockUser, type MockAuthResult, type MockUser, mockAuth, } from './auth.mock' -// Blocks mocks export { blocksMock, createMockGetBlock, @@ -42,18 +38,23 @@ export { mockToolConfigs, toolsUtilsMock, } from './blocks.mock' -// Database mocks export { createMockDb, + createMockDeleteChain, + createMockSelectChain, createMockSql, createMockSqlOperators, + createMockUpdateChain, databaseMock, drizzleOrmMock, } from './database.mock' -// Env mocks +export { createEditWorkflowRegistryMock } from './edit-workflow.mock' export { createEnvMock, createMockGetEnv, defaultMockEnv, envMock } from './env.mock' -// Executor mocks - use side-effect import: import '@sim/testing/mocks/executor' -// Fetch mocks +export { + createFeatureFlagsMock, + featureFlagsMock, + type MockFeatureFlags, +} from './feature-flags.mock' export { createMockFetch, createMockResponse, @@ -63,24 +64,21 @@ export { mockNextFetchResponse, setupGlobalFetchMock, } from './fetch.mock' -// Hybrid auth mocks export { AuthTypeMock, type MockHybridAuthResult, mockHybridAuth } from './hybrid-auth.mock' -// Logger mocks export { clearLoggerMocks, createMockLogger, getLoggerCalls, loggerMock } from './logger.mock' -// Redis mocks export { clearRedisMocks, createMockRedis, type MockRedis } from './redis.mock' -// Request mocks -export { createMockFormDataRequest, createMockRequest, requestUtilsMock } from './request.mock' -// Socket mocks +export { + asyncRouteParams, + createMockFormDataRequest, + createMockRequest, + requestUtilsMock, +} from './request.mock' export { createMockSocket, createMockSocketServer, type MockSocket, type MockSocketServer, } from './socket.mock' -// Storage mocks export { clearStorageMocks, createMockStorage, setupGlobalStorageMocks } from './storage.mock' -// Telemetry mocks export { telemetryMock } from './telemetry.mock' -// UUID mocks export { mockCryptoUuid, mockUuid } from './uuid.mock' diff --git a/packages/testing/src/mocks/request.mock.ts b/packages/testing/src/mocks/request.mock.ts index 5b8610d550..4f068de13d 100644 --- a/packages/testing/src/mocks/request.mock.ts +++ b/packages/testing/src/mocks/request.mock.ts @@ -59,6 +59,13 @@ export function createMockFormDataRequest( }) } +/** + * Creates the async `params` object used by App Router route handlers. + */ +export function asyncRouteParams>(params: T): Promise { + return Promise.resolve(params) +} + /** * Pre-configured mock for @/lib/core/utils/request module. * From f588b36914467d8e913a09691e3485dce12186dd Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Apr 2026 23:41:14 -0700 Subject: [PATCH 02/11] fix --- .../[tableId]/components/table/table.tsx | 50 ++++++++----------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 387643a245..344a02ebfb 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { @@ -2905,7 +2905,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ }) { const renameInputRef = useRef(null) - useLayoutEffect(() => { + useEffect(() => { if (isRenaming && renameInputRef.current) { renameInputRef.current.focus() renameInputRef.current.select() @@ -2958,6 +2958,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ const handleThPointerDown = useCallback( (e: React.PointerEvent) => { if (isRenaming || e.button !== 0) return + if ((e.target as HTMLElement).closest('button')) return e.preventDefault() const th = e.currentTarget as HTMLElement @@ -3041,36 +3042,27 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ )}
) : ( -
- - - {column.name} - - {sortDirection && ( - - - - )} - +
-
+ - e.preventDefault()} - > + onSortAsc?.(column.name)}> Sort ascending From 9e0fc2cd85333da7a4534acbed75342de029ca7b Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 4 Apr 2026 15:25:16 -0700 Subject: [PATCH 03/11] fixes --- .../components/row-modal/row-modal.tsx | 22 +++----- .../[tableId]/components/table/table.tsx | 51 ++++++++++--------- .../[tableId]/hooks/use-export-table.ts | 10 ++-- .../workspace/[workspaceId]/tables/tables.tsx | 5 +- 4 files changed, 42 insertions(+), 46 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx index 758b1b64c7..3df3026266 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx @@ -42,13 +42,9 @@ export interface RowModalProps { function createInitialRowData(columns: ColumnDefinition[]): Record { const initial: Record = {} - columns.forEach((col) => { - if (col.type === 'boolean') { - initial[col.name] = false - } else { - initial[col.name] = '' - } - }) + for (const col of columns) { + initial[col.name] = col.type === 'boolean' ? false : '' + } return initial } @@ -57,16 +53,13 @@ function cleanRowData( rowData: Record ): Record { const cleanData: Record = {} - - columns.forEach((col) => { - const value = rowData[col.name] + for (const col of columns) { try { - cleanData[col.name] = cleanCellValue(value, col) + cleanData[col.name] = cleanCellValue(rowData[col.name], col) } catch { throw new Error(`Invalid JSON for field: ${col.name}`) } - }) - + } return cleanData } @@ -89,8 +82,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess const workspaceId = params.workspaceId as string const tableId = table.id - const schema = table?.schema - const columns = schema?.columns || [] + const columns = table.schema?.columns || [] const [rowData, setRowData] = useState>(() => getInitialRowData(mode, columns, row) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 344a02ebfb..e25ab045e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -237,6 +237,7 @@ export function Table({ const ghostRef = useRef(null) const tableFilterRef = useRef(null) const isDraggingRef = useRef(false) + const dragEscapeCleanupRef = useRef<(() => void) | null>(null) const { tableData, isLoadingTable, rows, isLoadingRows } = useTableData({ workspaceId, @@ -715,7 +716,6 @@ export function Table({ const scrollRect = scroll.getBoundingClientRect() const tableEl = element.closest('table') - // Position the ghost at the dragged column's location ghost.style.left = `${dragged.left}px` ghost.style.width = `${dragged.width}px` ghost.style.height = `${tableEl ? tableEl.offsetHeight : scroll.scrollHeight}px` @@ -746,6 +746,7 @@ export function Table({ element.removeEventListener('pointercancel', handleCancel) document.removeEventListener('keydown', handleKeyDown) element.releasePointerCapture(pointerId) + dragEscapeCleanupRef.current = null if (shouldCommit) commit() ghost.style.display = 'none' ghost.style.transform = 'translateX(0)' @@ -758,7 +759,6 @@ export function Table({ const delta = ev.clientX - startX ghost.style.transform = `translateX(${delta}px)` - // Determine which column the cursor is over const tableX = ev.clientX - scrollRect.left + scroll.scrollLeft let target = boundaries[boundaries.length - 1] let side: 'left' | 'right' = 'right' @@ -771,7 +771,6 @@ export function Table({ } if (target.name === columnName) { - // Hovering over self — suppress the indicator if (dropTargetColumnNameRef.current !== null) { setDropTargetColumnName(null) } @@ -795,6 +794,7 @@ export function Table({ element.addEventListener('pointerup', handleUp) element.addEventListener('pointercancel', handleCancel) document.addEventListener('keydown', handleKeyDown) + dragEscapeCleanupRef.current = () => cleanup(false) }, [] ) @@ -833,6 +833,12 @@ export function Table({ return () => document.removeEventListener('mouseup', handleMouseUp) }, []) + useEffect(() => { + return () => { + dragEscapeCleanupRef.current?.() + } + }, []) + useEffect(() => { if (!selectionAnchor) return const { rowIndex, colIndex } = selectionAnchor @@ -1466,10 +1472,10 @@ export function Table({ }, []) const generateColumnName = useCallback(() => { - const existing = schemaColumnsRef.current.map((c) => c.name.toLowerCase()) + const existing = new Set(schemaColumnsRef.current.map((c) => c.name.toLowerCase())) let name = 'untitled' let i = 2 - while (existing.includes(name.toLowerCase())) { + while (existing.has(name)) { name = `untitled_${i}` i++ } @@ -2906,10 +2912,9 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ const renameInputRef = useRef(null) useEffect(() => { - if (isRenaming && renameInputRef.current) { - renameInputRef.current.focus() - renameInputRef.current.select() - } + if (!isRenaming || !renameInputRef.current) return + renameInputRef.current.focus() + renameInputRef.current.select() }, [isRenaming]) const handleResizePointerDown = useCallback( @@ -2958,7 +2963,6 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ const handleThPointerDown = useCallback( (e: React.PointerEvent) => { if (isRenaming || e.button !== 0) return - if ((e.target as HTMLElement).closest('button')) return e.preventDefault() const th = e.currentTarget as HTMLElement @@ -3042,24 +3046,25 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ )}
) : ( -
+
+ + + {column.name} + + {sortDirection && ( + + + + )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-export-table.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-export-table.ts index 8ddbae1a32..a2b6d39559 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-export-table.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-export-table.ts @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { usePostHog } from 'posthog-js/react' import { toast } from '@/components/emcn' import { downloadFile, sanitizePathSegment } from '@/lib/core/utils/file-download' @@ -29,12 +29,12 @@ export function useExportTable({ }: UseExportTableParams) { const posthog = usePostHog() const [isExporting, setIsExporting] = useState(false) + const isExportingRef = useRef(false) const handleExportTable = useCallback(async () => { - if (!canExport || !workspaceId || !tableId || isExporting) { - return - } + if (!canExport || !workspaceId || !tableId || isExportingRef.current) return + isExportingRef.current = true setIsExporting(true) try { @@ -63,12 +63,12 @@ export function useExportTable({ duration: 5000, }) } finally { + isExportingRef.current = false setIsExporting(false) } }, [ canExport, columns, - isExporting, posthog, queryOptions.filter, queryOptions.sort, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index a26aa3a18e..7596f6ccb6 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -68,9 +68,8 @@ export function Tables() { const { data: tables = [], isLoading, error } = useTablesList(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) - if (error) { - logger.error('Failed to load tables:', error) - } + if (error) logger.error('Failed to load tables:', error) + const deleteTable = useDeleteTable(workspaceId) const createTable = useCreateTable(workspaceId) const uploadCsv = useUploadCsvToTable() From f7d7bc1a43032aaed5455fbe52a26cf32565541e Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 4 Apr 2026 16:41:46 -0700 Subject: [PATCH 04/11] fix(tables): undo/redo gaps, escape regression, conflict marker - Add delete-column undo/redo support - Add undo tracking to RowModal (create/edit/delete) - Fix patchUndoRowId to also patch create-rows actions - Extract actual row position from API response (not -1) - Fix Escape key to preserve cell selection when editing - Remove stray conflict marker from modal.tsx --- .../components/row-modal/row-modal.tsx | 27 +- .../[tableId]/components/table/table.tsx | 361 +++++++----------- .../emcn/components/modal/modal.tsx | 1 - apps/sim/hooks/use-table-undo.ts | 15 + apps/sim/stores/table/store.ts | 6 + apps/sim/stores/table/types.ts | 8 + 6 files changed, 182 insertions(+), 236 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx index 3df3026266..7cea4aedcf 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx @@ -27,6 +27,7 @@ import { useDeleteTableRows, useUpdateTableRow, } from '@/hooks/queries/tables' +import { useTableUndoStore } from '@/stores/table/store' const logger = createLogger('RowModal') @@ -92,6 +93,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess const updateRowMutation = useUpdateTableRow({ workspaceId, tableId }) const deleteRowMutation = useDeleteTableRow({ workspaceId, tableId }) const deleteRowsMutation = useDeleteTableRows({ workspaceId, tableId }) + const pushToUndoStack = useTableUndoStore((s) => s.push) const isSubmitting = createRowMutation.isPending || updateRowMutation.isPending || @@ -106,9 +108,24 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess const cleanData = cleanRowData(columns, rowData) if (mode === 'add') { - await createRowMutation.mutateAsync({ data: cleanData }) + const response = await createRowMutation.mutateAsync({ data: cleanData }) + const createdRow = (response as { data?: { row?: { id?: string; position?: number } } }) + ?.data?.row + if (createdRow?.id) { + pushToUndoStack(tableId, { + type: 'create-row', + rowId: createdRow.id, + position: createdRow.position ?? 0, + data: cleanData, + }) + } } else if (mode === 'edit' && row) { + const oldData = row.data as Record await updateRowMutation.mutateAsync({ rowId: row.id, data: cleanData }) + pushToUndoStack(tableId, { + type: 'update-cells', + cells: [{ rowId: row.id, oldData, newData: cleanData }], + }) } onSuccess() @@ -124,8 +141,14 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess const idsToDelete = rowIds ?? (row ? [row.id] : []) try { - if (idsToDelete.length === 1) { + if (idsToDelete.length === 1 && row) { await deleteRowMutation.mutateAsync(idsToDelete[0]) + pushToUndoStack(tableId, { + type: 'delete-rows', + rows: [ + { rowId: row.id, data: row.data as Record, position: row.position }, + ], + }) } else { await deleteRowsMutation.mutateAsync(idsToDelete) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index e25ab045e2..adf35def3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -1,6 +1,7 @@ 'use client' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { GripVertical } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { @@ -234,10 +235,8 @@ export function Table({ const metadataSeededRef = useRef(false) const containerRef = useRef(null) const scrollRef = useRef(null) - const ghostRef = useRef(null) const tableFilterRef = useRef(null) const isDraggingRef = useRef(false) - const dragEscapeCleanupRef = useRef<(() => void) | null>(null) const { tableData, isLoadingTable, rows, isLoadingRows } = useTableData({ workspaceId, @@ -690,127 +689,41 @@ export function Table({ updateMetadataRef.current({ columnWidths: columnWidthsRef.current }) }, []) - const handleColumnDragStart = useCallback( - (columnName: string, element: HTMLElement, pointerId: number, startX: number) => { - element.setPointerCapture(pointerId) - - setDragColumnName(columnName) - - const scroll = scrollRef.current - const ghost = ghostRef.current - if (!scroll || !ghost) return - - const cols = columnsRef.current - const widths = columnWidthsRef.current - let left = CHECKBOX_COL_WIDTH - const boundaries: Array<{ name: string; left: number; width: number }> = [] - for (const col of cols) { - const w = widths[col.name] ?? COL_WIDTH - boundaries.push({ name: col.name, left, width: w }) - left += w - } - - const dragged = boundaries.find((b) => b.name === columnName) - if (!dragged) return - - const scrollRect = scroll.getBoundingClientRect() - const tableEl = element.closest('table') - - ghost.style.left = `${dragged.left}px` - ghost.style.width = `${dragged.width}px` - ghost.style.height = `${tableEl ? tableEl.offsetHeight : scroll.scrollHeight}px` - ghost.style.transform = 'translateX(0)' - ghost.style.display = 'block' - - const commit = () => { - const dragedName = dragColumnNameRef.current - const target = dropTargetColumnNameRef.current - const side = dropSideRef.current - if (dragedName && target && dragedName !== target) { - const currentOrder = columnOrderRef.current ?? columnsRef.current.map((c) => c.name) - const newOrder = currentOrder.filter((n) => n !== dragedName) - let insertIndex = newOrder.indexOf(target) - if (side === 'right') insertIndex += 1 - newOrder.splice(insertIndex, 0, dragedName) - setColumnOrder(newOrder) - updateMetadataRef.current({ - columnWidths: columnWidthsRef.current, - columnOrder: newOrder, - }) - } - } - - const cleanup = (shouldCommit: boolean) => { - element.removeEventListener('pointermove', handleMove) - element.removeEventListener('pointerup', handleUp) - element.removeEventListener('pointercancel', handleCancel) - document.removeEventListener('keydown', handleKeyDown) - element.releasePointerCapture(pointerId) - dragEscapeCleanupRef.current = null - if (shouldCommit) commit() - ghost.style.display = 'none' - ghost.style.transform = 'translateX(0)' - setDragColumnName(null) - setDropTargetColumnName(null) - setDropSide('left') - } - - const handleMove = (ev: PointerEvent) => { - const delta = ev.clientX - startX - ghost.style.transform = `translateX(${delta}px)` - - const tableX = ev.clientX - scrollRect.left + scroll.scrollLeft - let target = boundaries[boundaries.length - 1] - let side: 'left' | 'right' = 'right' - for (const b of boundaries) { - if (tableX < b.left + b.width) { - target = b - side = tableX < b.left + b.width / 2 ? 'left' : 'right' - break - } - } - - if (target.name === columnName) { - if (dropTargetColumnNameRef.current !== null) { - setDropTargetColumnName(null) - } - } else if ( - target.name !== dropTargetColumnNameRef.current || - side !== dropSideRef.current - ) { - setDropTargetColumnName(target.name) - setDropSide(side) - } - } - - const handleKeyDown = (ev: KeyboardEvent) => { - if (ev.key === 'Escape') cleanup(false) - } - - const handleUp = () => cleanup(true) - const handleCancel = () => cleanup(false) + const handleColumnDragStart = useCallback((columnName: string) => { + setDragColumnName(columnName) + }, []) - element.addEventListener('pointermove', handleMove) - element.addEventListener('pointerup', handleUp) - element.addEventListener('pointercancel', handleCancel) - document.addEventListener('keydown', handleKeyDown) - dragEscapeCleanupRef.current = () => cleanup(false) - }, - [] - ) + const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => { + if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return + setDropTargetColumnName(columnName) + setDropSide(side) + }, []) - const handleColumnSelect = useCallback((colIndex: number, shiftKey: boolean) => { - setCheckedRows(clearCheckedRows) - lastCheckboxRowRef.current = null - setEditingCell(null) - if (shiftKey && selectionAnchorRef.current) { - setSelectionFocus({ rowIndex: 0, colIndex }) - } else { - setSelectionAnchor({ rowIndex: 0, colIndex }) - setSelectionFocus({ rowIndex: 0, colIndex }) + const handleColumnDragEnd = useCallback(() => { + const dragged = dragColumnNameRef.current + if (!dragged) return + const target = dropTargetColumnNameRef.current + const side = dropSideRef.current + if (target && dragged !== target) { + const currentOrder = columnOrderRef.current ?? columnsRef.current.map((c) => c.name) + const newOrder = currentOrder.filter((n) => n !== dragged) + let insertIndex = newOrder.indexOf(target) + if (side === 'right') insertIndex += 1 + newOrder.splice(insertIndex, 0, dragged) + setColumnOrder(newOrder) + updateMetadataRef.current({ + columnWidths: columnWidthsRef.current, + columnOrder: newOrder, + }) } - setIsColumnSelection(true) - scrollRef.current?.focus({ preventScroll: true }) + setDragColumnName(null) + setDropTargetColumnName(null) + setDropSide('left') + }, []) + + const handleColumnDragLeave = useCallback(() => { + dropTargetColumnNameRef.current = null + setDropTargetColumnName(null) }, []) useEffect(() => { @@ -833,12 +746,6 @@ export function Table({ return () => document.removeEventListener('mouseup', handleMouseUp) }, []) - useEffect(() => { - return () => { - dragEscapeCleanupRef.current?.() - } - }, []) - useEffect(() => { if (!selectionAnchor) return const { rowIndex, colIndex } = selectionAnchor @@ -907,6 +814,12 @@ export function Table({ if (!el) return const handleKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement).tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { + if (e.key === 'Escape') setIsColumnSelection(false) + return + } + if (e.key === 'Escape') { e.preventDefault() isDraggingRef.current = false @@ -918,9 +831,6 @@ export function Table({ return } - const tag = (e.target as HTMLElement).tagName - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return - if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'y')) { e.preventDefault() if (e.key === 'y' || e.shiftKey) { @@ -1594,10 +1504,22 @@ export function Table({ const handleDeleteColumnConfirm = useCallback(() => { if (!deletingColumn) return const columnToDelete = deletingColumn + const column = schemaColumnsRef.current.find((c) => c.name === columnToDelete) + const position = schemaColumnsRef.current.findIndex((c) => c.name === columnToDelete) const orderAtDelete = columnOrderRef.current setDeletingColumn(null) deleteColumnMutation.mutate(columnToDelete, { onSuccess: () => { + if (column && position !== -1) { + pushUndoRef.current({ + type: 'delete-column', + columnName: columnToDelete, + columnType: column.type, + position, + unique: !!column.unique, + required: !!column.required, + }) + } if (!orderAtDelete) return const newOrder = orderAtDelete.filter((n) => n !== columnToDelete) setColumnOrder(newOrder) @@ -1908,11 +1830,10 @@ export function Table({ checked={isAllRowsSelected} onCheckedChange={handleSelectAllToggle} /> - {displayColumns.map((column, colIndex) => ( + {displayColumns.map((column) => ( = selectedColumnRange.start && - colIndex <= selectedColumnRange.end - } - onColumnSelect={handleColumnSelect} + onDragOver={handleColumnDragOver} + onDragEnd={handleColumnDragEnd} + onDragLeave={handleColumnDragLeave} sortDirection={ activeSortState?.column === column.name ? activeSortState.direction : null } @@ -2024,11 +1942,6 @@ export function Table({ style={{ left: dropIndicatorLeft }} /> )} -
{!isLoadingTable && !isLoadingRows && userPermissions.canEdit && ( @@ -2853,7 +2766,6 @@ const COLUMN_TYPE_OPTIONS: { type: string; label: string; icon: React.ElementTyp const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ column, - colIndex, readOnly, isRenaming, renameValue, @@ -2871,15 +2783,15 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onResizeEnd, isDragging, onDragStart, - isColumnSelected, - onColumnSelect, + onDragOver, + onDragEnd, + onDragLeave, sortDirection, onSortAsc, onSortDesc, onFilterColumn, }: { column: ColumnDefinition - colIndex: number readOnly?: boolean isRenaming: boolean renameValue: string @@ -2896,14 +2808,10 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onResize: (columnName: string, width: number) => void onResizeEnd: () => void isDragging?: boolean - onDragStart?: ( - columnName: string, - element: HTMLElement, - pointerId: number, - startX: number - ) => void - isColumnSelected?: boolean - onColumnSelect?: (colIndex: number, shiftKey: boolean) => void + onDragStart?: (columnName: string) => void + onDragOver?: (columnName: string, side: 'left' | 'right') => void + onDragEnd?: () => void + onDragLeave?: () => void sortDirection?: SortDirection | null onSortAsc?: (columnName: string) => void onSortDesc?: (columnName: string) => void @@ -2912,9 +2820,10 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ const renameInputRef = useRef(null) useEffect(() => { - if (!isRenaming || !renameInputRef.current) return - renameInputRef.current.focus() - renameInputRef.current.select() + if (isRenaming && renameInputRef.current) { + renameInputRef.current.focus() + renameInputRef.current.select() + } }, [isRenaming]) const handleResizePointerDown = useCallback( @@ -2948,74 +2857,58 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ [column.name, onResizeStart, onResize, onResizeEnd] ) - const [menuOpen, setMenuOpen] = useState(false) - - const handleContextMenu = useCallback( - (e: React.MouseEvent) => { - if (readOnly || isRenaming) return - e.preventDefault() - onColumnSelect?.(colIndex, false) - setMenuOpen(true) + const handleDragStart = useCallback( + (e: React.DragEvent) => { + if (readOnly || isRenaming) { + e.preventDefault() + return + } + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', column.name) + onDragStart?.(column.name) }, - [readOnly, isRenaming, colIndex, onColumnSelect] + [column.name, readOnly, isRenaming, onDragStart] ) - const handleThPointerDown = useCallback( - (e: React.PointerEvent) => { - if (isRenaming || e.button !== 0) return + const handleDragOver = useCallback( + (e: React.DragEvent) => { e.preventDefault() + e.dataTransfer.dropEffect = 'move' + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + const midX = rect.left + rect.width / 2 + const side = e.clientX < midX ? 'left' : 'right' + onDragOver?.(column.name, side) + }, + [column.name, onDragOver] + ) - const th = e.currentTarget as HTMLElement - const startX = e.clientX - const startY = e.clientY - const shiftKey = e.shiftKey - const pid = e.pointerId - th.setPointerCapture(pid) - let dragging = false - - const onMove = (ev: PointerEvent) => { - if (dragging) return - if (!readOnly && Math.hypot(ev.clientX - startX, ev.clientY - startY) > 4) { - dragging = true - th.removeEventListener('pointermove', onMove) - th.removeEventListener('pointerup', onUp) - th.removeEventListener('pointercancel', onCancel) - onDragStart?.(column.name, th, pid, ev.clientX) - } - } - - const onUp = () => { - th.removeEventListener('pointermove', onMove) - th.removeEventListener('pointerup', onUp) - th.removeEventListener('pointercancel', onCancel) - th.releasePointerCapture(pid) - if (!dragging) onColumnSelect?.(colIndex, shiftKey) - } + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + }, []) - const onCancel = () => { - th.removeEventListener('pointermove', onMove) - th.removeEventListener('pointerup', onUp) - th.removeEventListener('pointercancel', onCancel) - th.releasePointerCapture(pid) - } + const handleDragEnd = useCallback(() => { + onDragEnd?.() + }, [onDragEnd]) - th.addEventListener('pointermove', onMove) - th.addEventListener('pointerup', onUp) - th.addEventListener('pointercancel', onCancel) + const handleDragLeave = useCallback( + (e: React.DragEvent) => { + const th = e.currentTarget as HTMLElement + const related = e.relatedTarget as Node | null + if (related && th.contains(related)) return + onDragLeave?.() }, - [column.name, colIndex, isRenaming, readOnly, onColumnSelect, onDragStart] + [onDragLeave] ) return ( {isRenaming ? (
@@ -3039,35 +2932,28 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ {column.name} - {sortDirection && ( - - - - )}
) : ( -
- - - {column.name} - - {sortDirection && ( - - - - )} - +
+ - + onSortAsc?.(column.name)}> Sort ascending @@ -3124,11 +3010,20 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ +
+ +
)} -
e.stopPropagation()} onPointerDown={handleResizePointerDown} /> diff --git a/apps/sim/components/emcn/components/modal/modal.tsx b/apps/sim/components/emcn/components/modal/modal.tsx index b682733c33..5f9ff79360 100644 --- a/apps/sim/components/emcn/components/modal/modal.tsx +++ b/apps/sim/components/emcn/components/modal/modal.tsx @@ -166,7 +166,6 @@ const ModalContent = React.forwardRef< )} style={{ left: isWorkflowPage -<<<<<<< HEAD ? 'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)' : 'calc(var(--sidebar-width) / 2 + 50%)', ...style, diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 6090e84f1d..4180bc6b7b 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -191,6 +191,21 @@ export function useTableUndo({ workspaceId, tableId }: UseTableUndoProps) { break } + case 'delete-column': { + if (direction === 'undo') { + addColumnMutation.mutate({ + name: action.columnName, + type: action.columnType, + position: action.position, + unique: action.unique, + required: action.required, + }) + } else { + deleteColumnMutation.mutate(action.columnName) + } + break + } + case 'rename-column': { if (direction === 'undo') { updateColumnMutation.mutate({ diff --git a/apps/sim/stores/table/store.ts b/apps/sim/stores/table/store.ts index c3239e515d..b4f7c23f47 100644 --- a/apps/sim/stores/table/store.ts +++ b/apps/sim/stores/table/store.ts @@ -114,6 +114,12 @@ export const useTableUndoStore = create()( if (action.type === 'create-row' && action.rowId === oldRowId) { return { ...entry, action: { ...action, rowId: newRowId } } } + if (action.type === 'create-rows') { + const patchedRows = action.rows.map((r) => + r.rowId === oldRowId ? { ...r, rowId: newRowId } : r + ) + return { ...entry, action: { ...action, rows: patchedRows } } + } return entry }) diff --git a/apps/sim/stores/table/types.ts b/apps/sim/stores/table/types.ts index fbea638f01..1662ee0509 100644 --- a/apps/sim/stores/table/types.ts +++ b/apps/sim/stores/table/types.ts @@ -32,6 +32,14 @@ export type TableUndoAction = } | { type: 'delete-rows'; rows: DeletedRowSnapshot[] } | { type: 'create-column'; columnName: string; position: number } + | { + type: 'delete-column' + columnName: string + columnType: string + position: number + unique: boolean + required: boolean + } | { type: 'rename-column'; oldName: string; newName: string } | { type: 'update-column-type'; columnName: string; previousType: string; newType: string } | { From 12c527d7ea9821da508ab2921cc557160d010a12 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 4 Apr 2026 16:57:58 -0700 Subject: [PATCH 05/11] fix(tables): isColumnSelection dead code, paste column failure, drag indexOf guard --- .../[tableId]/components/table/table.tsx | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index adf35def3f..8ddebea440 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -707,8 +707,14 @@ export function Table({ if (target && dragged !== target) { const currentOrder = columnOrderRef.current ?? columnsRef.current.map((c) => c.name) const newOrder = currentOrder.filter((n) => n !== dragged) - let insertIndex = newOrder.indexOf(target) - if (side === 'right') insertIndex += 1 + const targetIndex = newOrder.indexOf(target) + if (targetIndex === -1) { + setDragColumnName(null) + setDropTargetColumnName(null) + setDropSide('left') + return + } + const insertIndex = side === 'right' ? targetIndex + 1 : targetIndex newOrder.splice(insertIndex, 0, dragged) setColumnOrder(newOrder) updateMetadataRef.current({ @@ -1214,19 +1220,23 @@ export function Table({ } // Create columns sequentially so each invalidation completes before the next + const createdColNames: string[] = [] try { for (const name of newColNames) { await addColumnAsyncRef.current({ name, type: 'string' }) + createdColNames.push(name) } } catch { - // If column creation fails, paste into whatever columns exist + // If column creation fails partway, paste into whatever columns were created } // Build updated column list locally — React Query cache may not have refreshed yet - currentCols = [ - ...currentCols, - ...newColNames.map((name) => ({ name, type: 'string' as const })), - ] + if (createdColNames.length > 0) { + currentCols = [ + ...currentCols, + ...createdColNames.map((name) => ({ name, type: 'string' as const })), + ] + } } const undoCells: Array<{ rowId: string; data: Record }> = [] @@ -1685,6 +1695,12 @@ export function Table({ [dragColumnName, displayColumns] ) + const handleColumnSelect = useCallback((colIndex: number) => { + setSelectionAnchor({ rowIndex: 0, colIndex }) + setSelectionFocus({ rowIndex: 0, colIndex }) + setIsColumnSelection(true) + }, []) + const handleSortAsc = useCallback( (columnName: string) => handleSortChange(columnName, 'asc'), [handleSortChange] @@ -1830,10 +1846,11 @@ export function Table({ checked={isAllRowsSelected} onCheckedChange={handleSelectAllToggle} /> - {displayColumns.map((column) => ( + {displayColumns.map((column, colIndex) => ( ))} {userPermissions.canEdit && ( @@ -2766,6 +2784,7 @@ const COLUMN_TYPE_OPTIONS: { type: string; label: string; icon: React.ElementTyp const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ column, + colIndex, readOnly, isRenaming, renameValue, @@ -2790,8 +2809,10 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onSortAsc, onSortDesc, onFilterColumn, + onColumnSelect, }: { column: ColumnDefinition + colIndex: number readOnly?: boolean isRenaming: boolean renameValue: string @@ -2816,6 +2837,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onSortAsc?: (columnName: string) => void onSortDesc?: (columnName: string) => void onFilterColumn?: (columnName: string) => void + onColumnSelect?: (colIndex: number) => void }) { const renameInputRef = useRef(null) @@ -2940,6 +2962,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({