diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index 80c59e537d..cdb7e0d17f 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -3,6 +3,9 @@ * * @vitest-environment node */ + +import { createFeatureFlagsMock, createMockRequest } from '@sim/testing' +import { drizzleOrmMock } from '@sim/testing/mocks' import type { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -10,7 +13,6 @@ const { mockVerifyCronAuth, mockExecuteScheduleJob, mockExecuteJobInline, - mockFeatureFlags, mockDbReturning, mockDbUpdate, mockEnqueue, @@ -33,12 +35,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 +45,13 @@ const { } }) +const mockFeatureFlags = createFeatureFlagsMock({ + isTriggerDevEnabled: false, + isHosted: false, + isProd: false, + isDev: true, +}) + vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth, })) @@ -91,17 +94,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 +170,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 +192,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 unknown as NextRequest) expect(response).toBeDefined() expect(response.status).toBe(200) @@ -217,7 +205,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 unknown as NextRequest) expect(response).toBeDefined() expect(response.status).toBe(200) @@ -228,7 +216,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 unknown as NextRequest) expect(response.status).toBe(200) const data = await response.json() @@ -239,7 +227,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 unknown as NextRequest) expect(response.status).toBe(200) const data = await response.json() @@ -249,7 +237,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 unknown as NextRequest) expect(response.status).toBe(200) expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith( @@ -274,7 +262,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 unknown as NextRequest) expect(response.status).toBe(200) expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith( diff --git a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts index 101d96896e..f0ecdc22d2 100644 --- a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts @@ -16,7 +16,8 @@ import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' -import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' +import { sanitizePathSegment } from '@/lib/core/utils/file-download' +import { exportFolderToZip } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { diff --git a/apps/sim/app/api/v1/admin/workflows/export/route.ts b/apps/sim/app/api/v1/admin/workflows/export/route.ts index d7cc28babd..75be85eea4 100644 --- a/apps/sim/app/api/v1/admin/workflows/export/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/export/route.ts @@ -20,7 +20,7 @@ import { createLogger } from '@sim/logger' import { inArray } from 'drizzle-orm' import JSZip from 'jszip' import { NextResponse } from 'next/server' -import { sanitizePathSegment } from '@/lib/workflows/operations/import-export' +import { sanitizePathSegment } from '@/lib/core/utils/file-download' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts index 6cd9055630..08c3eb3f6b 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts @@ -16,7 +16,8 @@ import { workflow, workflowFolder, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' -import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' +import { sanitizePathSegment } from '@/lib/core/utils/file-download' +import { exportWorkspaceToZip } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { 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..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 @@ -17,13 +17,17 @@ 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' +import { useTableUndoStore } from '@/stores/table/store' const logger = createLogger('RowModal') @@ -39,13 +43,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 } @@ -54,16 +54,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 } @@ -86,8 +83,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) @@ -97,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 || @@ -111,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() @@ -129,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-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..efb88c38a8 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 @@ -24,11 +24,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 +49,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 +84,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 +101,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 +210,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 +235,7 @@ export function Table({ const metadataSeededRef = useRef(false) const containerRef = useRef(null) const scrollRef = 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(() => { @@ -315,7 +332,18 @@ export function Table({ }, [resizingColumn, displayColumns, columnWidths]) const dropIndicatorLeft = useMemo(() => { - if (!dropTargetColumnName) return null + if (!dropTargetColumnName || !dragColumnName) return null + + const dragIdx = displayColumns.findIndex((c) => c.name === dragColumnName) + const targetIdx = displayColumns.findIndex((c) => c.name === dropTargetColumnName) + + if (dragIdx !== -1 && targetIdx !== -1) { + // Suppress when drop would be a no-op (same effective position) + if (targetIdx === dragIdx) return null + if (dropSide === 'right' && targetIdx === dragIdx - 1) return null + if (dropSide === 'left' && targetIdx === dragIdx + 1) return null + } + let left = CHECKBOX_COL_WIDTH for (const col of displayColumns) { if (dropSide === 'left' && col.name === dropTargetColumnName) return left @@ -323,7 +351,7 @@ export function Table({ if (dropSide === 'right' && col.name === dropTargetColumnName) return left } return null - }, [dropTargetColumnName, dropSide, displayColumns, columnWidths]) + }, [dropTargetColumnName, dropSide, displayColumns, columnWidths, dragColumnName]) const isAllRowsSelected = useMemo(() => { if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) { @@ -350,6 +378,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 +388,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 +604,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 +628,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 +659,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 +670,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) @@ -682,21 +716,22 @@ export function Table({ 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 currentOrder = columnOrderRef.current ?? columnsRef.current.map((c) => c.name) + const newOrder = currentOrder.filter((n) => n !== dragged) + 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({ + columnWidths: columnWidthsRef.current, + columnOrder: newOrder, + }) } setDragColumnName(null) setDropTargetColumnName(null) @@ -782,6 +817,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 @@ -794,27 +832,32 @@ export function Table({ const handleKeyDown = (e: KeyboardEvent) => { 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) { - redoRef.current() - } else { - undoRef.current() - } + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { + if (e.key === 'Escape') setIsColumnSelection(false) return } if (e.key === 'Escape') { e.preventDefault() + isDraggingRef.current = false setSelectionAnchor(null) setSelectionFocus(null) - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setIsColumnSelection(false) + setCheckedRows(clearCheckedRows) lastCheckboxRowRef.current = null return } + if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'y')) { + e.preventDefault() + if (e.key === 'y' || e.shiftKey) { + redoRef.current() + } else { + undoRef.current() + } + return + } + if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault() const rws = rowsRef.current @@ -822,6 +865,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 +879,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 +932,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 +954,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 +981,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 +991,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 +1027,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 +1059,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 +1096,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 +1152,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 +1188,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 +1207,48 @@ 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 + const createdColNames: string[] = [] + try { + for (const name of newColNames) { + await addColumnAsyncRef.current({ name, type: 'string' }) + createdColNames.push(name) + } + } catch { + // If column creation fails partway, paste into whatever columns were created + } + + // Build updated column list locally — React Query cache may not have refreshed yet + if (createdColNames.length > 0) { + currentCols = [ + ...currentCols, + ...createdColNames.map((name) => ({ name, type: 'string' as const })), + ] + } + } const undoCells: Array<{ rowId: string; data: Record }> = [] const updateBatch: Array<{ rowId: string; data: Record }> = [] @@ -1245,7 +1328,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), @@ -1321,10 +1403,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++ } @@ -1429,7 +1511,10 @@ export function Table({ }, []) const handleRenameColumn = useCallback( - (name: string) => columnRename.startRename(name, name), + (name: string) => { + isDraggingRef.current = false + columnRename.startRename(name, name) + }, [columnRename.startRename] ) @@ -1440,10 +1525,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) @@ -1468,13 +1565,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 +1667,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 +1696,32 @@ 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 handleColumnSelect = useCallback((colIndex: number) => { + setSelectionAnchor({ rowIndex: 0, colIndex }) + setSelectionFocus({ rowIndex: 0, colIndex }) + setIsColumnSelection(true) + }, []) + + 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 +1778,12 @@ export function Table({
{!embedded && ( <> - + {filterOpen && ( )} @@ -1691,10 +1857,11 @@ export function Table({ checked={isAllRowsSelected} onCheckedChange={handleSelectAllToggle} /> - {displayColumns.map((column) => ( + {displayColumns.map((column, colIndex) => ( ))} {userPermissions.canEdit && ( @@ -1744,6 +1921,7 @@ export function Table({ startPosition={prevPosition + 1} columns={displayColumns} normalizedSelection={normalizedSelection} + draggingColIndex={draggingColIndex} checkedRows={checkedRows} firstRowUnderHeader={prevPosition === -1} onCellMouseDown={handleCellMouseDown} @@ -1766,6 +1944,7 @@ export function Table({ : null } normalizedSelection={normalizedSelection} + draggingColIndex={draggingColIndex} onClick={handleCellClick} onDoubleClick={handleCellDoubleClick} onSave={handleInlineSave} @@ -1917,6 +2096,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 +2110,7 @@ const PositionGapRows = React.memo( startPosition, columns, normalizedSelection, + draggingColIndex, checkedRows, firstRowUnderHeader = false, onCellMouseDown, @@ -1995,7 +2176,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 +2225,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 +2268,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 +2319,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 +2356,7 @@ const DataRow = React.memo(function DataRow({ initialCharacter, pendingCellValue, normalizedSelection, + draggingColIndex, isRowChecked, onClick, onDoubleClick, @@ -2235,7 +2424,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 +2798,7 @@ const COLUMN_TYPE_OPTIONS: { type: string; label: string; icon: React.ElementTyp const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ column, + colIndex, readOnly, isRenaming, renameValue, @@ -2621,12 +2815,19 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onResize, onResizeEnd, isDragging, + isDropTarget, onDragStart, onDragOver, onDragEnd, onDragLeave, + sortDirection, + onSortAsc, + onSortDesc, + onFilterColumn, + onColumnSelect, }: { column: ColumnDefinition + colIndex: number readOnly?: boolean isRenaming: boolean renameValue: string @@ -2643,10 +2844,16 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onResize: (columnName: string, width: number) => void onResizeEnd: () => void isDragging?: boolean + isDropTarget?: boolean 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 + onFilterColumn?: (columnName: string) => void + onColumnSelect?: (colIndex: number) => void }) { const renameInputRef = useRef(null) @@ -2735,7 +2942,8 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ - + {column.name}
@@ -2771,15 +2979,34 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ + onSortAsc?.(column.name)}> + + Sort ascending + + onSortDesc?.(column.name)}> + + Sort descending + + onFilterColumn?.(column.name)}> + + Filter by this column + + onRenameColumn(column.name)}> Rename column @@ -2900,3 +3127,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..a2b6d39559 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-export-table.ts @@ -0,0 +1,84 @@ +'use client' + +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' +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 isExportingRef = useRef(false) + + const handleExportTable = useCallback(async () => { + if (!canExport || !workspaceId || !tableId || isExportingRef.current) return + + isExportingRef.current = true + 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 { + isExportingRef.current = false + setIsExporting(false) + } + }, [ + canExport, + columns, + 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/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() diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts index ff85f17e93..479028659f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts @@ -1,13 +1,12 @@ import { useCallback, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' +import { downloadFile, sanitizePathSegment } from '@/lib/core/utils/file-download' import { getFolderById } from '@/lib/folders/tree' import { - downloadFile, exportFolderToZip, type FolderExportData, fetchWorkflowForExport, - sanitizePathSegment, type WorkflowExportData, } from '@/lib/workflows/operations/import-export' import { useFolderMap } from '@/hooks/queries/folders' diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts index deed7a4e1d..4ecc4cfa62 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts @@ -1,8 +1,8 @@ import { useCallback, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' +import { downloadFile } from '@/lib/core/utils/file-download' import { - downloadFile, exportWorkflowsToZip, type FolderExportData, fetchWorkflowForExport, diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts index d237ab3984..d7d8367078 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts @@ -2,13 +2,12 @@ import { useCallback, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' +import { downloadFile, sanitizePathSegment } from '@/lib/core/utils/file-download' import { captureEvent } from '@/lib/posthog/client' import { - downloadFile, exportWorkflowsToZip, exportWorkflowToJson, fetchWorkflowForExport, - sanitizePathSegment, } from '@/lib/workflows/operations/import-export' import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { useFolderStore } from '@/stores/folders/store' diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts index 8fde033a5d..905bedc8c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts @@ -1,11 +1,10 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' +import { downloadFile, sanitizePathSegment } from '@/lib/core/utils/file-download' import { - downloadFile, exportWorkspaceToZip, type FolderExportData, fetchWorkflowForExport, - sanitizePathSegment, type WorkflowExportData, } from '@/lib/workflows/operations/import-export' diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts index ebce2c5312..3ced5886d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts @@ -3,12 +3,12 @@ import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' +import { sanitizePathSegment } from '@/lib/core/utils/file-download' import { captureEvent } from '@/lib/posthog/client' import { extractWorkflowsFromFiles, extractWorkflowsFromZip, persistImportedWorkflow, - sanitizePathSegment, } from '@/lib/workflows/operations/import-export' import { useCreateFolder } from '@/hooks/queries/folders' import { folderKeys } from '@/hooks/queries/utils/folder-keys' diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts index 6b6b5df81a..5343129944 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts @@ -1,11 +1,11 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' +import { sanitizePathSegment } from '@/lib/core/utils/file-download' import { extractWorkflowName, extractWorkflowsFromZip, parseWorkflowJson, - sanitizePathSegment, } from '@/lib/workflows/operations/import-export' import { useCreateFolder } from '@/hooks/queries/folders' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' diff --git a/apps/sim/components/emcn/components/modal/modal.tsx b/apps/sim/components/emcn/components/modal/modal.tsx index f9afb95b6d..5f9ff79360 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,7 @@ 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)' + ? '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/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/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..8018cadce5 100644 --- a/apps/sim/lib/workflows/comparison/compare.test.ts +++ b/apps/sim/lib/workflows/comparison/compare.test.ts @@ -1,9 +1,12 @@ /** * Tests for workflow change detection comparison logic */ + +import type { WorkflowVariableFixture } from '@sim/testing' 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 +49,12 @@ function createBlock(id: string, overrides: Record = {}): any { }) } +function createVariablesMap( + ...variables: Parameters[0] +): Record { + return createWorkflowVariablesMap(variables) +} + describe('hasWorkflowChanged', () => { describe('Basic Cases', () => { it.concurrent('should return true when deployedState is null', () => { @@ -2181,9 +2190,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 +2204,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 +2223,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 +2247,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 +2261,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 +2285,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 +2333,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 +2357,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 +2381,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) @@ -2840,175 +2875,135 @@ describe('hasWorkflowChanged', () => { describe('Variables (UI-only fields should not trigger change)', () => { it.concurrent('should not detect change when validationError differs', () => { const deployedState = createWorkflowState({ - blocks: { - block1: createBlock('block1'), - }, - }) - ;(deployedState as any).variables = { - var1: { + blocks: { block1: createBlock('block1') }, + variables: createVariablesMap({ id: 'var1', workflowId: 'workflow1', name: 'myVar', type: 'plain', value: 'test', - }, - } + }), + }) const currentState = createWorkflowState({ - blocks: { - block1: createBlock('block1'), - }, - }) - ;(currentState as any).variables = { - var1: { + blocks: { block1: createBlock('block1') }, + variables: createVariablesMap({ id: 'var1', workflowId: 'workflow1', name: 'myVar', type: 'plain', value: 'test', validationError: undefined, - }, - } + }), + }) expect(hasWorkflowChanged(currentState, deployedState)).toBe(false) }) it.concurrent('should not detect change when validationError has value vs missing', () => { const deployedState = createWorkflowState({ - blocks: { - block1: createBlock('block1'), - }, - }) - ;(deployedState as any).variables = { - var1: { + blocks: { block1: createBlock('block1') }, + variables: createVariablesMap({ id: 'var1', workflowId: 'workflow1', name: 'myVar', type: 'number', value: 'invalid', - }, - } + }), + }) const currentState = createWorkflowState({ - blocks: { - block1: createBlock('block1'), - }, - }) - ;(currentState as any).variables = { - var1: { + blocks: { block1: createBlock('block1') }, + variables: createVariablesMap({ id: 'var1', workflowId: 'workflow1', name: 'myVar', type: 'number', value: 'invalid', validationError: 'Not a valid number', - }, - } + }), + }) expect(hasWorkflowChanged(currentState, deployedState)).toBe(false) }) it.concurrent('should detect change when variable value differs', () => { const deployedState = createWorkflowState({ - blocks: { - block1: createBlock('block1'), - }, - }) - ;(deployedState as any).variables = { - var1: { + blocks: { block1: createBlock('block1') }, + 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: { + blocks: { block1: createBlock('block1') }, + variables: createVariablesMap({ id: 'var1', workflowId: 'workflow1', name: 'myVar', type: 'plain', value: 'new value', - validationError: undefined, - }, - } + }), + }) expect(hasWorkflowChanged(currentState, deployedState)).toBe(true) }) it.concurrent('should detect change when variable is added', () => { const deployedState = createWorkflowState({ - blocks: { - block1: createBlock('block1'), - }, + blocks: { block1: createBlock('block1') }, + variables: {}, }) - ;(deployedState as any).variables = {} const currentState = createWorkflowState({ - blocks: { - block1: createBlock('block1'), - }, - }) - ;(currentState as any).variables = { - var1: { + blocks: { block1: createBlock('block1') }, + variables: createVariablesMap({ id: 'var1', workflowId: 'workflow1', name: 'myVar', type: 'plain', value: 'test', - }, - } + }), + }) expect(hasWorkflowChanged(currentState, deployedState)).toBe(true) }) it.concurrent('should detect change when variable is removed', () => { const deployedState = createWorkflowState({ - blocks: { - block1: createBlock('block1'), - }, - }) - ;(deployedState as any).variables = { - var1: { + blocks: { block1: createBlock('block1') }, + variables: createVariablesMap({ id: 'var1', workflowId: 'workflow1', name: 'myVar', type: 'plain', value: 'test', - }, - } + }), + }) const currentState = createWorkflowState({ - blocks: { - block1: createBlock('block1'), - }, + blocks: { block1: createBlock('block1') }, + variables: {}, }) - ;(currentState as any).variables = {} expect(hasWorkflowChanged(currentState, deployedState)).toBe(true) }) it.concurrent('should not detect change when empty array vs empty object', () => { const deployedState = createWorkflowState({ - blocks: { - block1: createBlock('block1'), - }, + blocks: { block1: createBlock('block1') }, }) - ;(deployedState as any).variables = [] + // Intentional type violation to test robustness with malformed data + ;(deployedState as unknown as Record).variables = [] const currentState = createWorkflowState({ - blocks: { - block1: createBlock('block1'), - }, + blocks: { block1: createBlock('block1') }, + variables: {}, }) - ;(currentState as any).variables = {} expect(hasWorkflowChanged(currentState, deployedState)).toBe(false) }) @@ -3151,7 +3146,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 +3156,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..8631653019 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, @@ -43,36 +44,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/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 } | { 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..a667092417 --- /dev/null +++ b/packages/testing/src/mocks/edit-workflow.mock.ts @@ -0,0 +1,55 @@ +const editWorkflowBlockConfigs: Record< + string, + { + type: string + name: string + outputs: Record + subBlocks: { id: string; type: string }[] + } +> = { + 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. *