mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
11 Commits
v0.6.25
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aef5b54b01 | ||
|
|
5fe71484e3 | ||
|
|
5933877023 | ||
|
|
4f7459250c | ||
|
|
194a2d38e4 | ||
|
|
8b9367e217 | ||
|
|
12c527d7ea | ||
|
|
f7d7bc1a43 | ||
|
|
9e0fc2cd85 | ||
|
|
f588b36914 | ||
|
|
eba424c8a3 |
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export type { TableFilterHandle } from './table-filter'
|
||||
export { TableFilter } from './table-filter'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]' />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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""}"'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './use-context-menu'
|
||||
export * from './use-export-table'
|
||||
export * from './use-table-data'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
36
apps/sim/lib/core/utils/file-download.ts
Normal file
36
apps/sim/lib/core/utils/file-download.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
| {
|
||||
|
||||
@@ -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'
|
||||
|
||||
12
packages/testing/src/factories/table.factory.test.ts
Normal file
12
packages/testing/src/factories/table.factory.test.ts
Normal 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_]*$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
62
packages/testing/src/factories/table.factory.ts
Normal file
62
packages/testing/src/factories/table.factory.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
53
packages/testing/src/factories/workflow-variable.factory.ts
Normal file
53
packages/testing/src/factories/workflow-variable.factory.ts
Normal 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]
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
55
packages/testing/src/mocks/edit-workflow.mock.ts
Normal file
55
packages/testing/src/mocks/edit-workflow.mock.ts
Normal 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],
|
||||
}
|
||||
}
|
||||
65
packages/testing/src/mocks/feature-flags.mock.ts
Normal file
65
packages/testing/src/mocks/feature-flags.mock.ts
Normal 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()
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user