Compare commits

...

11 Commits

Author SHA1 Message Date
waleed
aef5b54b01 improvement(tables): restore column drop target background highlight 2026-04-04 17:22:15 -07:00
waleed
5fe71484e3 fix(tables): remove leftover AbortController from use-export-table 2026-04-04 17:22:15 -07:00
waleed
5933877023 fix: direct import for sanitizePathSegment in use-import-workspace 2026-04-04 17:22:15 -07:00
waleed
4f7459250c fix(tables): direct imports for downloadFile/sanitizePathSegment, fix greptile comments 2026-04-04 17:22:15 -07:00
waleed
194a2d38e4 improvement(tables): suppress drag indicator when drop would be no-op 2026-04-04 17:21:48 -07:00
waleed
8b9367e217 fix(tables): remove any types, clean up test variable assignments 2026-04-04 17:21:48 -07:00
waleed
12c527d7ea fix(tables): isColumnSelection dead code, paste column failure, drag indexOf guard 2026-04-04 17:21:48 -07:00
waleed
f7d7bc1a43 fix(tables): undo/redo gaps, escape regression, conflict marker
- Add delete-column undo/redo support
- Add undo tracking to RowModal (create/edit/delete)
- Fix patchUndoRowId to also patch create-rows actions
- Extract actual row position from API response (not -1)
- Fix Escape key to preserve cell selection when editing
- Remove stray conflict marker from modal.tsx
2026-04-04 17:21:48 -07:00
waleed
9e0fc2cd85 fixes 2026-04-04 17:21:48 -07:00
waleed
f588b36914 fix 2026-04-04 17:21:48 -07:00
waleed
eba424c8a3 improvement(tables): ops and experience 2026-04-04 17:21:48 -07:00
49 changed files with 1215 additions and 523 deletions

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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<string, unknown> {
const initial: Record<string, unknown> = {}
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<string, unknown>
): Record<string, unknown> {
const cleanData: Record<string, unknown> = {}
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<Record<string, unknown>>(() =>
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<string, unknown>
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<string, unknown>, position: row.position },
],
})
} else {
await deleteRowsMutation.mutateAsync(idsToDelete)
}

View File

@@ -1 +1,2 @@
export type { TableFilterHandle } from './table-filter'
export { TableFilter } from './table-filter'

View File

@@ -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<string, string>
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<TableFilterHandle, TableFilterProps>(function TableFilter(
{ columns, filter, onApply, onClose, initialColumn },
ref
) {
const [rules, setRules] = useState<FilterRule[]>(() => {
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
</div>
</div>
)
}
})
interface FilterRuleRowProps {
rule: FilterRule

View File

@@ -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<number>()
const clearCheckedRows = (prev: Set<number>) => (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<string | null>(null)
const [selectionAnchor, setSelectionAnchor] = useState<CellCoord | null>(null)
const [selectionFocus, setSelectionFocus] = useState<CellCoord | null>(null)
const [isColumnSelection, setIsColumnSelection] = useState(false)
const [checkedRows, setCheckedRows] = useState(EMPTY_CHECKED_ROWS)
const lastCheckboxRowRef = useRef<number | null>(null)
const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false)
@@ -220,6 +235,7 @@ export function Table({
const metadataSeededRef = useRef(false)
const containerRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const tableFilterRef = useRef<TableFilterHandle>(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<number>()
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,7 +832,21 @@ export function Table({
const handleKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
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)
setIsColumnSelection(false)
setCheckedRows(clearCheckedRows)
lastCheckboxRowRef.current = null
return
}
if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'y')) {
e.preventDefault()
@@ -806,15 +858,6 @@ export function Table({
return
}
if (e.key === 'Escape') {
e.preventDefault()
setSelectionAnchor(null)
setSelectionFocus(null)
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
lastCheckboxRowRef.current = null
return
}
if ((e.metaKey || e.ctrlKey) && e.key === 'a') {
e.preventDefault()
const rws = rowsRef.current
@@ -822,6 +865,7 @@ export function Table({
setEditingCell(null)
setSelectionAnchor(null)
setSelectionFocus(null)
setIsColumnSelection(false)
const all = new Set<number>()
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<string, unknown> }> = []
@@ -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<string, unknown> }> = []
const updateBatch: Array<{ rowId: string; data: Record<string, unknown> }> = []
@@ -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<string | null>(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<ColumnOption[]>(
@@ -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<SortConfig>(
() => ({
options: columnOptions,
@@ -1619,7 +1778,12 @@ export function Table({
<div ref={containerRef} className='flex h-full flex-col overflow-hidden'>
{!embedded && (
<>
<ResourceHeader icon={TableIcon} breadcrumbs={breadcrumbs} create={createAction} />
<ResourceHeader
icon={TableIcon}
breadcrumbs={breadcrumbs}
actions={headerActions}
create={createAction}
/>
<ResourceOptionsBar
sort={sortConfig}
@@ -1628,10 +1792,12 @@ export function Table({
/>
{filterOpen && (
<TableFilter
ref={tableFilterRef}
columns={displayColumns}
filter={queryOptions.filter}
onApply={handleFilterApply}
onClose={handleFilterClose}
initialColumn={initialFilterColumn}
/>
)}
</>
@@ -1691,10 +1857,11 @@ export function Table({
checked={isAllRowsSelected}
onCheckedChange={handleSelectAllToggle}
/>
{displayColumns.map((column) => (
{displayColumns.map((column, colIndex) => (
<ColumnHeaderMenu
key={column.name}
column={column}
colIndex={colIndex}
readOnly={!userPermissions.canEdit}
isRenaming={columnRename.editingId === column.name}
renameValue={
@@ -1713,10 +1880,20 @@ export function Table({
onResize={handleColumnResize}
onResizeEnd={handleColumnResizeEnd}
isDragging={dragColumnName === column.name}
isDropTarget={
dropTargetColumnName === column.name && dropIndicatorLeft !== null
}
onDragStart={handleColumnDragStart}
onDragOver={handleColumnDragOver}
onDragEnd={handleColumnDragEnd}
onDragLeave={handleColumnDragLeave}
sortDirection={
activeSortState?.column === column.name ? activeSortState.direction : null
}
onSortAsc={handleSortAsc}
onSortDesc={handleSortDesc}
onFilterColumn={handleFilterByColumn}
onColumnSelect={handleColumnSelect}
/>
))}
{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<number>
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<string, unknown> | 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<HTMLInputElement>(null)
@@ -2735,7 +2942,8 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
<th
className={cn(
'group relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle',
isDragging && 'opacity-40'
isDragging && 'opacity-40',
isDropTarget && 'bg-[var(--selection)]/10'
)}
onDragOver={handleDragOver}
onDrop={handleDrop}
@@ -2760,7 +2968,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
) : readOnly ? (
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
<ColumnTypeIcon type={column.type} />
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
{column.name}
</span>
</div>
@@ -2771,15 +2979,34 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
<button
type='button'
className='flex min-w-0 flex-1 cursor-pointer items-center px-2 py-[7px] outline-none'
onClick={() => onColumnSelect?.(colIndex)}
>
<ColumnTypeIcon type={column.type} />
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
{column.name}
</span>
{sortDirection && (
<span className='ml-1 shrink-0'>
<SortDirectionIndicator direction={sortDirection} />
</span>
)}
<ChevronDown className='ml-1.5 h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem onSelect={() => onSortAsc?.(column.name)}>
<ArrowUp />
Sort ascending
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSortDesc?.(column.name)}>
<ArrowDown />
Sort descending
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onFilterColumn?.(column.name)}>
<ListFilter />
Filter by this column
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onRenameColumn(column.name)}>
<Pencil />
Rename column
@@ -2900,3 +3127,11 @@ function ColumnTypeIcon({ type }: { type: string }) {
const Icon = COLUMN_TYPE_ICONS[type] ?? TypeText
return <Icon className='h-3 w-3 shrink-0 text-[var(--text-icon)]' />
}
function SortDirectionIndicator({ direction }: { direction: SortDirection }) {
return direction === 'asc' ? (
<ArrowUp className='h-[10px] w-[10px] text-[var(--text-muted)]' />
) : (
<ArrowDown className='h-[10px] w-[10px] text-[var(--text-muted)]' />
)
}

View File

@@ -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""}"'
)
})
})

View File

@@ -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')
}

View File

@@ -1,2 +1,3 @@
export * from './use-context-menu'
export * from './use-export-table'
export * from './use-table-data'

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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,

View File

@@ -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()

View File

@@ -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'

View File

@@ -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,

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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,
}}

View File

@@ -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<TableRowsParams, 'workspaceId' | 'tableId' | 'filter' | 'sort'> & {
pageSize?: number
signal?: AbortSignal
}): Promise<TableRowsResponse> {
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<typeof useQueryClient>, tableId: string) {
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
}

View File

@@ -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({

View File

@@ -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', () => {

View File

@@ -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('@sim/logger', () => loggerMock)
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<string, any> = {
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('@/blocks/registry', () =>
createEditWorkflowRegistryMock(['condition', 'agent', 'function'])
)
function makeLoopWorkflow() {
return {

View File

@@ -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', () => {

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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' }),
],
}

View File

@@ -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<string, any> = {}): any {
})
}
function createVariablesMap(
...variables: Parameters<typeof createWorkflowVariablesMap>[0]
): Record<string, WorkflowVariableFixture> {
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<string, unknown>).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)

View File

@@ -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<T>(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<void>) =>
callback(tx)

View File

@@ -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.

View File

@@ -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<void>) =>
callback(tx)

View File

@@ -114,6 +114,12 @@ export const useTableUndoStore = create<TableUndoState>()(
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
})

View File

@@ -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 }
| {

View File

@@ -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'

View File

@@ -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_]*$/)
}
})
})

View File

@@ -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<string, unknown>
position: number
createdAt: string
updatedAt: string
}
export interface TableColumnFactoryOptions {
name?: string
type?: TableColumnType
required?: boolean
unique?: boolean
}
export interface TableRowFactoryOptions {
id?: string
data?: Record<string, unknown>
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,
}
}

View File

@@ -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<string, WorkflowVariableFixture> {
return Object.fromEntries(
variables.map((variable) => {
const fixture = createWorkflowVariable(variable)
return [fixture.id, fixture]
})
)
}

View File

@@ -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,

View File

@@ -103,6 +103,38 @@ export function createMockDb() {
}
}
/**
* Creates a select chain that resolves from `where()`.
*/
export function createMockSelectChain<T>(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<T>(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<T>(result: T = [] as T) {
return {
where: vi.fn().mockResolvedValue(result),
}
}
/**
* Mock module for @sim/db.
* Use with vi.mock() to replace the real database.

View File

@@ -0,0 +1,55 @@
const editWorkflowBlockConfigs: Record<
string,
{
type: string
name: string
outputs: Record<string, unknown>
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],
}
}

View File

@@ -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> = {}
): 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()

View File

@@ -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'

View File

@@ -59,6 +59,13 @@ export function createMockFormDataRequest(
})
}
/**
* Creates the async `params` object used by App Router route handlers.
*/
export function asyncRouteParams<T extends Record<string, unknown>>(params: T): Promise<T> {
return Promise.resolve(params)
}
/**
* Pre-configured mock for @/lib/core/utils/request module.
*