Compare commits

..

4 Commits

Author SHA1 Message Date
Waleed
a35f6eca03 improvement(tools): use react query to fetch child workflow schema, avoid refetch and duplicated utils, consolidated utils and testing mocks (#2839)
* improvement(tools): use react query to fetch child workflow schema, avoid refetch and duplicated utils

* consolidated utils & testing mocks
2026-01-15 13:25:22 -08:00
Waleed
1cc489e544 feat(workflow-controls): added action bar for workflow controls (#2767)
* feat(workflow-controls): added action bar for picker/hand/undo/redo/zoom workflow controls, added general setting to disable

* added util for fit to zoom that accounts for sidebar, terminal, and panel

* ack PR comments

* remove dead state variable, add logs

* improvement(ui/ux): action bar, panel, tooltip, dragging, invite modal

* added fit to view in canvas context menu

* fix(theme): dark mode flash

* fix: duplicate fit to view

* refactor: popovers; improvement: notifications, diff controls, action bar

* improvement(action-bar): ui/ux

* refactor(action-bar): renamed to workflow controls

* ran migrations

* fix: deleted migration

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-01-15 13:25:00 -08:00
Vikhyath Mondreti
e499cc4f82 improvement(webhooks): lifecycle management with external providers, remove save configuration (#2831)
* fix(webhooks): lifecycle code accuracy

* remove save configuration button

* remove useless instruction

* address greptile comments

* fix lint

* on undeploy cleanup webhooks
2026-01-15 12:42:05 -08:00
Waleed
5e44357b9f improvement(snapshot): show subblocks for trigger only blocks in frozen canvas (#2838)
* improvement(snapshot): show subblocks for trigger only blocks in frozen canvas

* ack comment
2026-01-15 10:34:38 -08:00
156 changed files with 15518 additions and 6211 deletions

View File

@@ -0,0 +1,11 @@
'use client'
import { Tooltip } from '@/components/emcn'
interface TooltipProviderProps {
children: React.ReactNode
}
export function TooltipProvider({ children }: TooltipProviderProps) {
return <Tooltip.Provider>{children}</Tooltip.Provider>
}

View File

@@ -58,6 +58,25 @@
pointer-events: none !important;
}
/**
* Workflow canvas cursor styles
* Override React Flow's default selection cursor based on canvas mode
*/
.workflow-container.canvas-mode-cursor .react-flow__pane,
.workflow-container.canvas-mode-cursor .react-flow__selectionpane {
cursor: default !important;
}
.workflow-container.canvas-mode-hand .react-flow__pane,
.workflow-container.canvas-mode-hand .react-flow__selectionpane {
cursor: grab !important;
}
.workflow-container.canvas-mode-hand .react-flow__pane:active,
.workflow-container.canvas-mode-hand .react-flow__selectionpane:active {
cursor: grabbing !important;
}
/**
* Selected node ring indicator
* Uses a pseudo-element overlay to match the original behavior (absolute inset-0 z-40)
@@ -657,6 +676,20 @@ input[type="search"]::-ms-clear {
}
}
/**
* Notification toast enter animation
*/
@keyframes notification-enter {
from {
opacity: 0;
transform: translateX(-16px);
}
to {
opacity: 1;
transform: translateX(var(--stack-offset, 0px));
}
}
/**
* @depricated
* Legacy globals (light/dark) kept for backward-compat with old classes.

File diff suppressed because it is too large Load Diff

View File

@@ -3,13 +3,60 @@
*
* @vitest-environment node
*/
import {
createMockRequest,
mockConsoleLogger,
mockCryptoUuid,
mockDrizzleOrm,
mockUuid,
setupCommonApiMocks,
} from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, setupAuthApiMocks } from '@/app/api/__test-utils__/utils'
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn(() => 'https://app.example.com'),
}))
/** Setup auth API mocks for testing authentication routes */
function setupAuthApiMocks(
options: {
operations?: {
forgetPassword?: { success?: boolean; error?: string }
resetPassword?: { success?: boolean; error?: string }
}
} = {}
) {
setupCommonApiMocks()
mockUuid()
mockCryptoUuid()
mockConsoleLogger()
mockDrizzleOrm()
const { operations = {} } = options
const defaultOperations = {
forgetPassword: { success: true, error: 'Forget password error', ...operations.forgetPassword },
resetPassword: { success: true, error: 'Reset password error', ...operations.resetPassword },
}
const createAuthMethod = (config: { success?: boolean; error?: string }) => {
return vi.fn().mockImplementation(() => {
if (config.success) {
return Promise.resolve()
}
return Promise.reject(new Error(config.error))
})
}
vi.doMock('@/lib/auth', () => ({
auth: {
api: {
forgetPassword: createAuthMethod(defaultOperations.forgetPassword),
resetPassword: createAuthMethod(defaultOperations.resetPassword),
},
},
}))
}
describe('Forget Password API Route', () => {
beforeEach(() => {
vi.resetModules()

View File

@@ -3,8 +3,8 @@
*
* @vitest-environment node
*/
import { createMockLogger, createMockRequest } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockLogger, createMockRequest } from '@/app/api/__test-utils__/utils'
describe('OAuth Connections API Route', () => {
const mockGetSession = vi.fn()

View File

@@ -4,9 +4,9 @@
* @vitest-environment node
*/
import { createMockLogger } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockLogger } from '@/app/api/__test-utils__/utils'
describe('OAuth Credentials API Route', () => {
const mockGetSession = vi.fn()

View File

@@ -3,8 +3,8 @@
*
* @vitest-environment node
*/
import { createMockLogger, createMockRequest } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockLogger, createMockRequest } from '@/app/api/__test-utils__/utils'
describe('OAuth Disconnect API Route', () => {
const mockGetSession = vi.fn()

View File

@@ -3,8 +3,8 @@
*
* @vitest-environment node
*/
import { createMockLogger, createMockRequest } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockLogger, createMockRequest } from '@/app/api/__test-utils__/utils'
describe('OAuth Token API Routes', () => {
const mockGetUserId = vi.fn()

View File

@@ -3,8 +3,55 @@
*
* @vitest-environment node
*/
import {
createMockRequest,
mockConsoleLogger,
mockCryptoUuid,
mockDrizzleOrm,
mockUuid,
setupCommonApiMocks,
} from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, setupAuthApiMocks } from '@/app/api/__test-utils__/utils'
/** Setup auth API mocks for testing authentication routes */
function setupAuthApiMocks(
options: {
operations?: {
forgetPassword?: { success?: boolean; error?: string }
resetPassword?: { success?: boolean; error?: string }
}
} = {}
) {
setupCommonApiMocks()
mockUuid()
mockCryptoUuid()
mockConsoleLogger()
mockDrizzleOrm()
const { operations = {} } = options
const defaultOperations = {
forgetPassword: { success: true, error: 'Forget password error', ...operations.forgetPassword },
resetPassword: { success: true, error: 'Reset password error', ...operations.resetPassword },
}
const createAuthMethod = (config: { success?: boolean; error?: string }) => {
return vi.fn().mockImplementation(() => {
if (config.success) {
return Promise.resolve()
}
return Promise.reject(new Error(config.error))
})
}
vi.doMock('@/lib/auth', () => ({
auth: {
api: {
forgetPassword: createAuthMethod(defaultOperations.forgetPassword),
resetPassword: createAuthMethod(defaultOperations.resetPassword),
},
},
}))
}
describe('Reset Password API Route', () => {
beforeEach(() => {

View File

@@ -5,7 +5,34 @@
*/
import { loggerMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest } from '@/app/api/__test-utils__/utils'
/**
* Creates a mock NextRequest with cookies support for testing.
*/
function createMockNextRequest(
method = 'GET',
body?: unknown,
headers: Record<string, string> = {},
url = 'http://localhost:3000/api/test'
): any {
const headersObj = new Headers({
'Content-Type': 'application/json',
...headers,
})
return {
method,
headers: headersObj,
cookies: {
get: vi.fn().mockReturnValue(undefined),
},
json:
body !== undefined
? vi.fn().mockResolvedValue(body)
: vi.fn().mockRejectedValue(new Error('No body')),
url,
}
}
const createMockStream = () => {
return new ReadableStream({
@@ -71,10 +98,15 @@ vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
}))
vi.mock('@/lib/core/security/encryption', () => ({
decryptSecret: vi.fn().mockResolvedValue({ decrypted: 'test-password' }),
}))
describe('Chat Identifier API Route', () => {
const mockAddCorsHeaders = vi.fn().mockImplementation((response) => response)
const mockValidateChatAuth = vi.fn().mockResolvedValue({ authorized: true })
const mockSetChatAuthCookie = vi.fn()
const mockValidateAuthToken = vi.fn().mockReturnValue(false)
const mockChatResult = [
{
@@ -114,11 +146,16 @@ describe('Chat Identifier API Route', () => {
beforeEach(() => {
vi.resetModules()
vi.doMock('@/app/api/chat/utils', () => ({
vi.doMock('@/lib/core/security/deployment', () => ({
addCorsHeaders: mockAddCorsHeaders,
validateAuthToken: mockValidateAuthToken,
setDeploymentAuthCookie: vi.fn(),
isEmailAllowed: vi.fn().mockReturnValue(false),
}))
vi.doMock('@/app/api/chat/utils', () => ({
validateChatAuth: mockValidateChatAuth,
setChatAuthCookie: mockSetChatAuthCookie,
validateAuthToken: vi.fn().mockReturnValue(true),
}))
// Mock logger - use loggerMock from @sim/testing
@@ -175,7 +212,7 @@ describe('Chat Identifier API Route', () => {
describe('GET endpoint', () => {
it('should return chat info for a valid identifier', async () => {
const req = createMockRequest('GET')
const req = createMockNextRequest('GET')
const params = Promise.resolve({ identifier: 'test-chat' })
const { GET } = await import('@/app/api/chat/[identifier]/route')
@@ -206,7 +243,7 @@ describe('Chat Identifier API Route', () => {
}
})
const req = createMockRequest('GET')
const req = createMockNextRequest('GET')
const params = Promise.resolve({ identifier: 'nonexistent' })
const { GET } = await import('@/app/api/chat/[identifier]/route')
@@ -240,7 +277,7 @@ describe('Chat Identifier API Route', () => {
}
})
const req = createMockRequest('GET')
const req = createMockNextRequest('GET')
const params = Promise.resolve({ identifier: 'inactive-chat' })
const { GET } = await import('@/app/api/chat/[identifier]/route')
@@ -261,7 +298,7 @@ describe('Chat Identifier API Route', () => {
error: 'auth_required_password',
}))
const req = createMockRequest('GET')
const req = createMockNextRequest('GET')
const params = Promise.resolve({ identifier: 'password-protected-chat' })
const { GET } = await import('@/app/api/chat/[identifier]/route')
@@ -282,7 +319,7 @@ describe('Chat Identifier API Route', () => {
describe('POST endpoint', () => {
it('should handle authentication requests without input', async () => {
const req = createMockRequest('POST', { password: 'test-password' })
const req = createMockNextRequest('POST', { password: 'test-password' })
const params = Promise.resolve({ identifier: 'password-protected-chat' })
const { POST } = await import('@/app/api/chat/[identifier]/route')
@@ -298,7 +335,7 @@ describe('Chat Identifier API Route', () => {
})
it('should return 400 for requests without input', async () => {
const req = createMockRequest('POST', {})
const req = createMockNextRequest('POST', {})
const params = Promise.resolve({ identifier: 'test-chat' })
const { POST } = await import('@/app/api/chat/[identifier]/route')
@@ -319,7 +356,7 @@ describe('Chat Identifier API Route', () => {
error: 'Authentication required',
}))
const req = createMockRequest('POST', { input: 'Hello' })
const req = createMockNextRequest('POST', { input: 'Hello' })
const params = Promise.resolve({ identifier: 'protected-chat' })
const { POST } = await import('@/app/api/chat/[identifier]/route')
@@ -350,7 +387,7 @@ describe('Chat Identifier API Route', () => {
},
})
const req = createMockRequest('POST', { input: 'Hello' })
const req = createMockNextRequest('POST', { input: 'Hello' })
const params = Promise.resolve({ identifier: 'test-chat' })
const { POST } = await import('@/app/api/chat/[identifier]/route')
@@ -369,7 +406,10 @@ describe('Chat Identifier API Route', () => {
})
it('should return streaming response for valid chat messages', async () => {
const req = createMockRequest('POST', { input: 'Hello world', conversationId: 'conv-123' })
const req = createMockNextRequest('POST', {
input: 'Hello world',
conversationId: 'conv-123',
})
const params = Promise.resolve({ identifier: 'test-chat' })
const { POST } = await import('@/app/api/chat/[identifier]/route')
@@ -401,7 +441,7 @@ describe('Chat Identifier API Route', () => {
}, 10000)
it('should handle streaming response body correctly', async () => {
const req = createMockRequest('POST', { input: 'Hello world' })
const req = createMockNextRequest('POST', { input: 'Hello world' })
const params = Promise.resolve({ identifier: 'test-chat' })
const { POST } = await import('@/app/api/chat/[identifier]/route')
@@ -431,7 +471,7 @@ describe('Chat Identifier API Route', () => {
throw new Error('Execution failed')
})
const req = createMockRequest('POST', { input: 'Trigger error' })
const req = createMockNextRequest('POST', { input: 'Trigger error' })
const params = Promise.resolve({ identifier: 'test-chat' })
const { POST } = await import('@/app/api/chat/[identifier]/route')
@@ -470,7 +510,7 @@ describe('Chat Identifier API Route', () => {
})
it('should pass conversationId to streaming execution when provided', async () => {
const req = createMockRequest('POST', {
const req = createMockNextRequest('POST', {
input: 'Hello world',
conversationId: 'test-conversation-123',
})
@@ -492,7 +532,7 @@ describe('Chat Identifier API Route', () => {
})
it('should handle missing conversationId gracefully', async () => {
const req = createMockRequest('POST', { input: 'Hello world' })
const req = createMockNextRequest('POST', { input: 'Hello world' })
const params = Promise.resolve({ identifier: 'test-chat' })
const { POST } = await import('@/app/api/chat/[identifier]/route')

View File

@@ -3,9 +3,9 @@
*
* @vitest-environment node
*/
import { mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@/app/api/__test-utils__/utils'
describe('Copilot API Keys API Route', () => {
const mockFetch = vi.fn()

View File

@@ -3,14 +3,9 @@
*
* @vitest-environment node
*/
import { createMockRequest, mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
mockAuth,
mockCryptoUuid,
setupCommonApiMocks,
} from '@/app/api/__test-utils__/utils'
describe('Copilot Chat Delete API Route', () => {
const mockDelete = vi.fn()

View File

@@ -3,14 +3,9 @@
*
* @vitest-environment node
*/
import { createMockRequest, mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
mockAuth,
mockCryptoUuid,
setupCommonApiMocks,
} from '@/app/api/__test-utils__/utils'
describe('Copilot Chat Update Messages API Route', () => {
const mockSelect = vi.fn()

View File

@@ -3,8 +3,8 @@
*
* @vitest-environment node
*/
import { mockCryptoUuid, setupCommonApiMocks } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mockCryptoUuid, setupCommonApiMocks } from '@/app/api/__test-utils__/utils'
describe('Copilot Chats List API Route', () => {
const mockSelect = vi.fn()

View File

@@ -3,14 +3,9 @@
*
* @vitest-environment node
*/
import { createMockRequest, mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
mockAuth,
mockCryptoUuid,
setupCommonApiMocks,
} from '@/app/api/__test-utils__/utils'
describe('Copilot Checkpoints Revert API Route', () => {
const mockSelect = vi.fn()

View File

@@ -3,14 +3,9 @@
*
* @vitest-environment node
*/
import { createMockRequest, mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
mockAuth,
mockCryptoUuid,
setupCommonApiMocks,
} from '@/app/api/__test-utils__/utils'
describe('Copilot Checkpoints API Route', () => {
const mockSelect = vi.fn()

View File

@@ -3,14 +3,9 @@
*
* @vitest-environment node
*/
import { createMockRequest, mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
mockAuth,
mockCryptoUuid,
setupCommonApiMocks,
} from '@/app/api/__test-utils__/utils'
describe('Copilot Confirm API Route', () => {
const mockRedisExists = vi.fn()

View File

@@ -3,13 +3,9 @@
*
* @vitest-environment node
*/
import { createMockRequest, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
mockCryptoUuid,
setupCommonApiMocks,
} from '@/app/api/__test-utils__/utils'
describe('Copilot Feedback API Route', () => {
const mockInsert = vi.fn()

View File

@@ -3,13 +3,9 @@
*
* @vitest-environment node
*/
import { createMockRequest, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
mockCryptoUuid,
setupCommonApiMocks,
} from '@/app/api/__test-utils__/utils'
describe('Copilot Stats API Route', () => {
const mockFetch = vi.fn()

View File

@@ -1,5 +1,87 @@
import {
createMockRequest,
mockAuth,
mockCryptoUuid,
mockUuid,
setupCommonApiMocks,
} from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, setupFileApiMocks } from '@/app/api/__test-utils__/utils'
/** Setup file API mocks for file delete tests */
function setupFileApiMocks(
options: {
authenticated?: boolean
storageProvider?: 's3' | 'blob' | 'local'
cloudEnabled?: boolean
} = {}
) {
const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options
setupCommonApiMocks()
mockUuid()
mockCryptoUuid()
const authMocks = mockAuth()
if (authenticated) {
authMocks.setAuthenticated()
} else {
authMocks.setUnauthenticated()
}
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: authenticated,
userId: authenticated ? 'test-user-id' : undefined,
error: authenticated ? undefined : 'Unauthorized',
}),
}))
vi.doMock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn().mockResolvedValue(true),
verifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true),
}))
const uploadFileMock = vi.fn().mockResolvedValue({
path: '/api/files/serve/test-key.txt',
key: 'test-key.txt',
name: 'test.txt',
size: 100,
type: 'text/plain',
})
const downloadFileMock = vi.fn().mockResolvedValue(Buffer.from('test content'))
const deleteFileMock = vi.fn().mockResolvedValue(undefined)
const hasCloudStorageMock = vi.fn().mockReturnValue(cloudEnabled)
vi.doMock('@/lib/uploads', () => ({
getStorageProvider: vi.fn().mockReturnValue(storageProvider),
isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled),
StorageService: {
uploadFile: uploadFileMock,
downloadFile: downloadFileMock,
deleteFile: deleteFileMock,
hasCloudStorage: hasCloudStorageMock,
},
uploadFile: uploadFileMock,
downloadFile: downloadFileMock,
deleteFile: deleteFileMock,
hasCloudStorage: hasCloudStorageMock,
}))
vi.doMock('@/lib/uploads/core/storage-service', () => ({
uploadFile: uploadFileMock,
downloadFile: downloadFileMock,
deleteFile: deleteFileMock,
hasCloudStorage: hasCloudStorageMock,
}))
vi.doMock('fs/promises', () => ({
unlink: vi.fn().mockResolvedValue(undefined),
access: vi.fn().mockResolvedValue(undefined),
stat: vi.fn().mockResolvedValue({ isFile: () => true }),
}))
return { auth: authMocks }
}
describe('File Delete API Route', () => {
beforeEach(() => {

View File

@@ -1,12 +1,59 @@
import path from 'path'
import { NextRequest } from 'next/server'
/**
* Tests for file parse API route
*
* @vitest-environment node
*/
import {
createMockRequest,
mockAuth,
mockCryptoUuid,
mockUuid,
setupCommonApiMocks,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, setupFileApiMocks } from '@/app/api/__test-utils__/utils'
function setupFileApiMocks(
options: {
authenticated?: boolean
storageProvider?: 's3' | 'blob' | 'local'
cloudEnabled?: boolean
} = {}
) {
const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options
setupCommonApiMocks()
mockUuid()
mockCryptoUuid()
const authMocks = mockAuth()
if (authenticated) {
authMocks.setAuthenticated()
} else {
authMocks.setUnauthenticated()
}
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: authenticated,
userId: authenticated ? 'test-user-id' : undefined,
error: authenticated ? undefined : 'Unauthorized',
}),
}))
vi.doMock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn().mockResolvedValue(true),
verifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true),
}))
vi.doMock('@/lib/uploads', () => ({
getStorageProvider: vi.fn().mockReturnValue(storageProvider),
isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled),
}))
return { auth: authMocks }
}
const mockJoin = vi.fn((...args: string[]): string => {
if (args[0] === '/test/uploads') {

View File

@@ -1,6 +1,6 @@
import { mockAuth, mockCryptoUuid, mockUuid, setupCommonApiMocks } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setupFileApiMocks } from '@/app/api/__test-utils__/utils'
/**
* Tests for file presigned API route
@@ -8,6 +8,106 @@ import { setupFileApiMocks } from '@/app/api/__test-utils__/utils'
* @vitest-environment node
*/
function setupFileApiMocks(
options: {
authenticated?: boolean
storageProvider?: 's3' | 'blob' | 'local'
cloudEnabled?: boolean
} = {}
) {
const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options
setupCommonApiMocks()
mockUuid()
mockCryptoUuid()
const authMocks = mockAuth()
if (authenticated) {
authMocks.setAuthenticated()
} else {
authMocks.setUnauthenticated()
}
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: authenticated,
userId: authenticated ? 'test-user-id' : undefined,
error: authenticated ? undefined : 'Unauthorized',
}),
}))
vi.doMock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn().mockResolvedValue(true),
verifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true),
}))
const useBlobStorage = storageProvider === 'blob' && cloudEnabled
const useS3Storage = storageProvider === 's3' && cloudEnabled
vi.doMock('@/lib/uploads/config', () => ({
USE_BLOB_STORAGE: useBlobStorage,
USE_S3_STORAGE: useS3Storage,
UPLOAD_DIR: '/uploads',
getStorageConfig: vi.fn().mockReturnValue(
useBlobStorage
? {
accountName: 'testaccount',
accountKey: 'testkey',
connectionString: 'testconnection',
containerName: 'testcontainer',
}
: {
bucket: 'test-bucket',
region: 'us-east-1',
}
),
isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled),
getStorageProvider: vi
.fn()
.mockReturnValue(
storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local'
),
}))
const mockGeneratePresignedUploadUrl = vi.fn().mockImplementation(async (opts) => {
const timestamp = Date.now()
const safeFileName = opts.fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
const key = `${opts.context}/${timestamp}-ik3a6w4-${safeFileName}`
return {
url: 'https://example.com/presigned-url',
key,
}
})
vi.doMock('@/lib/uploads/core/storage-service', () => ({
hasCloudStorage: vi.fn().mockReturnValue(cloudEnabled),
generatePresignedUploadUrl: mockGeneratePresignedUploadUrl,
generatePresignedDownloadUrl: vi.fn().mockResolvedValue('https://example.com/presigned-url'),
}))
vi.doMock('@/lib/uploads/utils/validation', () => ({
validateFileType: vi.fn().mockReturnValue(null),
}))
vi.doMock('@/lib/uploads', () => ({
CopilotFiles: {
generateCopilotUploadUrl: vi.fn().mockResolvedValue({
url: 'https://example.com/presigned-url',
key: 'copilot/test-key.txt',
}),
isImageFileType: vi.fn().mockReturnValue(true),
},
getStorageProvider: vi
.fn()
.mockReturnValue(
storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local'
),
isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled),
}))
return { auth: authMocks }
}
describe('/api/files/presigned', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -210,7 +310,7 @@ describe('/api/files/presigned', () => {
const data = await response.json()
expect(response.status).toBe(200)
expect(data.fileInfo.key).toMatch(/^kb\/.*knowledge-doc\.pdf$/)
expect(data.fileInfo.key).toMatch(/^knowledge-base\/.*knowledge-doc\.pdf$/)
expect(data.directUploadSupported).toBe(true)
})

View File

@@ -1,11 +1,49 @@
import { NextRequest } from 'next/server'
/**
* Tests for file serve API route
*
* @vitest-environment node
*/
import {
defaultMockUser,
mockAuth,
mockCryptoUuid,
mockUuid,
setupCommonApiMocks,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setupApiTestMocks } from '@/app/api/__test-utils__/utils'
function setupApiTestMocks(
options: {
authenticated?: boolean
user?: { id: string; email: string }
withFileSystem?: boolean
withUploadUtils?: boolean
} = {}
) {
const { authenticated = true, user = defaultMockUser, withFileSystem = false } = options
setupCommonApiMocks()
mockUuid()
mockCryptoUuid()
const authMocks = mockAuth(user)
if (authenticated) {
authMocks.setAuthenticated(user)
} else {
authMocks.setUnauthenticated()
}
if (withFileSystem) {
vi.doMock('fs/promises', () => ({
readFile: vi.fn().mockResolvedValue(Buffer.from('test content')),
access: vi.fn().mockResolvedValue(undefined),
stat: vi.fn().mockResolvedValue({ isFile: () => true, size: 100 }),
}))
}
return { auth: authMocks }
}
describe('File Serve API Route', () => {
beforeEach(() => {
@@ -31,6 +69,17 @@ describe('File Serve API Route', () => {
existsSync: vi.fn().mockReturnValue(true),
}))
vi.doMock('@/lib/uploads', () => ({
CopilotFiles: {
downloadCopilotFile: vi.fn(),
},
isUsingCloudStorage: vi.fn().mockReturnValue(false),
}))
vi.doMock('@/lib/uploads/utils/file-utils', () => ({
inferContextFromKey: vi.fn().mockReturnValue('workspace'),
}))
vi.doMock('@/app/api/files/utils', () => ({
FileNotFoundError: class FileNotFoundError extends Error {
constructor(message: string) {
@@ -126,6 +175,17 @@ describe('File Serve API Route', () => {
verifyFileAccess: vi.fn().mockResolvedValue(true),
}))
vi.doMock('@/lib/uploads', () => ({
CopilotFiles: {
downloadCopilotFile: vi.fn(),
},
isUsingCloudStorage: vi.fn().mockReturnValue(false),
}))
vi.doMock('@/lib/uploads/utils/file-utils', () => ({
inferContextFromKey: vi.fn().mockReturnValue('workspace'),
}))
const req = new NextRequest(
'http://localhost:3000/api/files/serve/workspace/test-workspace-id/nested-path-file.txt'
)

View File

@@ -1,11 +1,76 @@
import { NextRequest } from 'next/server'
/**
* Tests for file upload API route
*
* @vitest-environment node
*/
import { mockAuth, mockCryptoUuid, mockUuid, setupCommonApiMocks } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setupFileApiMocks } from '@/app/api/__test-utils__/utils'
function setupFileApiMocks(
options: {
authenticated?: boolean
storageProvider?: 's3' | 'blob' | 'local'
cloudEnabled?: boolean
} = {}
) {
const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options
setupCommonApiMocks()
mockUuid()
mockCryptoUuid()
const authMocks = mockAuth()
if (authenticated) {
authMocks.setAuthenticated()
} else {
authMocks.setUnauthenticated()
}
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: authenticated,
userId: authenticated ? 'test-user-id' : undefined,
error: authenticated ? undefined : 'Unauthorized',
}),
}))
vi.doMock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn().mockResolvedValue(true),
verifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true),
verifyKBFileAccess: vi.fn().mockResolvedValue(true),
verifyCopilotFileAccess: vi.fn().mockResolvedValue(true),
}))
vi.doMock('@/lib/uploads/contexts/workspace', () => ({
uploadWorkspaceFile: vi.fn().mockResolvedValue({
id: 'test-file-id',
name: 'test.txt',
url: '/api/files/serve/workspace/test-workspace-id/test-file.txt',
size: 100,
type: 'text/plain',
key: 'workspace/test-workspace-id/1234567890-test.txt',
uploadedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
}),
}))
const uploadFileMock = vi.fn().mockResolvedValue({
path: '/api/files/serve/test-key.txt',
key: 'test-key.txt',
name: 'test.txt',
size: 100,
type: 'text/plain',
})
vi.doMock('@/lib/uploads', () => ({
getStorageProvider: vi.fn().mockReturnValue(storageProvider),
isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled),
uploadFile: uploadFileMock,
}))
return { auth: authMocks }
}
describe('File Upload API Route', () => {
const createMockFormData = (files: File[], context = 'workspace'): FormData => {

View File

@@ -3,15 +3,24 @@
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
type CapturedFolderValues,
createMockRequest,
type MockUser,
mockAuth,
mockLogger,
mockConsoleLogger,
setupCommonApiMocks,
} from '@/app/api/__test-utils__/utils'
} from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
/** Type for captured folder values in tests */
interface CapturedFolderValues {
name?: string
color?: string
parentId?: string | null
isExpanded?: boolean
sortOrder?: number
updatedAt?: Date
}
interface FolderDbMockOptions {
folderLookupResult?: any
@@ -21,6 +30,8 @@ interface FolderDbMockOptions {
}
describe('Individual Folder API Route', () => {
let mockLogger: ReturnType<typeof mockConsoleLogger>
const TEST_USER: MockUser = {
id: 'user-123',
email: 'test@example.com',
@@ -39,7 +50,8 @@ describe('Individual Folder API Route', () => {
updatedAt: new Date('2024-01-01T00:00:00Z'),
}
const { mockAuthenticatedUser, mockUnauthenticated } = mockAuth(TEST_USER)
let mockAuthenticatedUser: (user?: MockUser) => void
let mockUnauthenticated: () => void
const mockGetUserEntityPermissions = vi.fn()
function createFolderDbMock(options: FolderDbMockOptions = {}) {
@@ -110,6 +122,10 @@ describe('Individual Folder API Route', () => {
vi.resetModules()
vi.clearAllMocks()
setupCommonApiMocks()
mockLogger = mockConsoleLogger()
const auth = mockAuth(TEST_USER)
mockAuthenticatedUser = auth.mockAuthenticatedUser
mockUnauthenticated = auth.mockUnauthenticated
mockGetUserEntityPermissions.mockResolvedValue('admin')

View File

@@ -3,17 +3,46 @@
*
* @vitest-environment node
*/
import { createMockRequest, mockAuth, mockConsoleLogger, setupCommonApiMocks } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
type CapturedFolderValues,
createMockRequest,
createMockTransaction,
mockAuth,
mockLogger,
setupCommonApiMocks,
} from '@/app/api/__test-utils__/utils'
interface CapturedFolderValues {
name?: string
color?: string
parentId?: string | null
isExpanded?: boolean
sortOrder?: number
updatedAt?: Date
}
function createMockTransaction(mockData: {
selectData?: Array<{ id: string; [key: string]: unknown }>
insertResult?: Array<{ id: string; [key: string]: unknown }>
}) {
const { selectData = [], insertResult = [] } = mockData
return vi.fn().mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue(selectData),
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue(insertResult),
}),
}),
}
return await callback(tx)
})
}
describe('Folders API Route', () => {
let mockLogger: ReturnType<typeof mockConsoleLogger>
const mockFolders = [
{
id: 'folder-1',
@@ -41,7 +70,8 @@ describe('Folders API Route', () => {
},
]
const { mockAuthenticatedUser, mockUnauthenticated } = mockAuth()
let mockAuthenticatedUser: () => void
let mockUnauthenticated: () => void
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
const mockSelect = vi.fn()
@@ -63,6 +93,10 @@ describe('Folders API Route', () => {
})
setupCommonApiMocks()
mockLogger = mockConsoleLogger()
const auth = mockAuth()
mockAuthenticatedUser = auth.mockAuthenticatedUser
mockUnauthenticated = auth.mockUnauthenticated
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })

View File

@@ -9,6 +9,7 @@ import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deploymen
import { generateRequestId } from '@/lib/core/utils/request'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -34,22 +35,17 @@ async function getWorkflowInputSchema(workflowId: string): Promise<any[]> {
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
// Find the start block (starter or start_trigger type)
const startBlock = blocks.find(
(block) => block.type === 'starter' || block.type === 'start_trigger'
(block) =>
block.type === 'starter' || block.type === 'start_trigger' || block.type === 'input_trigger'
)
if (!startBlock) {
return []
}
// Extract inputFormat from subBlocks
const subBlocks = startBlock.subBlocks as Record<string, any> | null
if (!subBlocks?.inputFormat?.value) {
return []
}
return Array.isArray(subBlocks.inputFormat.value) ? subBlocks.inputFormat.value : []
return normalizeInputFormatValue(subBlocks?.inputFormat?.value)
} catch (error) {
logger.error('Error fetching workflow input schema:', error)
return []

View File

@@ -3,10 +3,9 @@
*
* @vitest-environment node
*/
import { loggerMock } from '@sim/testing'
import { createMockRequest, loggerMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest } from '@/app/api/__test-utils__/utils'
vi.mock('@/lib/execution/isolated-vm', () => ({
executeInIsolatedVM: vi.fn().mockImplementation(async (req) => {

View File

@@ -3,14 +3,14 @@
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
mockAuth,
mockConsoleLogger,
mockDrizzleOrm,
mockKnowledgeSchemas,
} from '@/app/api/__test-utils__/utils'
} from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
mockKnowledgeSchemas()

View File

@@ -3,14 +3,14 @@
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
mockAuth,
mockConsoleLogger,
mockDrizzleOrm,
mockKnowledgeSchemas,
} from '@/app/api/__test-utils__/utils'
} from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
mockKnowledgeSchemas()

View File

@@ -3,14 +3,14 @@
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
mockAuth,
mockConsoleLogger,
mockDrizzleOrm,
mockKnowledgeSchemas,
} from '@/app/api/__test-utils__/utils'
} from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
mockKnowledgeSchemas()
mockDrizzleOrm()

View File

@@ -3,14 +3,14 @@
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
mockAuth,
mockConsoleLogger,
mockDrizzleOrm,
mockKnowledgeSchemas,
} from '@/app/api/__test-utils__/utils'
} from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
mockKnowledgeSchemas()
mockDrizzleOrm()

View File

@@ -5,13 +5,13 @@
*
* @vitest-environment node
*/
import { createEnvMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createEnvMock,
createMockRequest,
mockConsoleLogger,
mockKnowledgeSchemas,
} from '@/app/api/__test-utils__/utils'
} from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('drizzle-orm', () => ({
and: vi.fn().mockImplementation((...args) => ({ and: args })),

View File

@@ -3,10 +3,9 @@
*
* @vitest-environment node
*/
import { loggerMock } from '@sim/testing'
import { createMockRequest, loggerMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest } from '@/app/api/__test-utils__/utils'
describe('Custom Tools API Routes', () => {
const sampleTools = [
@@ -364,7 +363,7 @@ describe('Custom Tools API Routes', () => {
})
it('should reject requests missing tool ID', async () => {
const req = createMockRequest('DELETE')
const req = new NextRequest('http://localhost:3000/api/tools/custom')
const { DELETE } = await import('@/app/api/tools/custom/route')

View File

@@ -27,10 +27,11 @@ const SettingsSchema = z.object({
superUserModeEnabled: z.boolean().optional(),
errorNotificationsEnabled: z.boolean().optional(),
snapToGridSize: z.number().min(0).max(50).optional(),
showActionBar: z.boolean().optional(),
})
const defaultSettings = {
theme: 'system',
theme: 'dark',
autoConnect: true,
telemetryEnabled: true,
emailPreferences: {},
@@ -39,6 +40,7 @@ const defaultSettings = {
superUserModeEnabled: false,
errorNotificationsEnabled: true,
snapToGridSize: 0,
showActionBar: true,
}
export async function GET() {
@@ -73,6 +75,7 @@ export async function GET() {
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
snapToGridSize: userSettings.snapToGridSize ?? 0,
showActionBar: userSettings.showActionBar ?? true,
},
},
{ status: 200 }

View File

@@ -1,6 +1,8 @@
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { generateRequestId } from '@/lib/core/utils/request'
import { cleanupWebhooksForWorkflow } from '@/lib/webhooks/deploy'
import {
deployWorkflow,
loadWorkflowFromNormalizedTables,
@@ -80,10 +82,11 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
const requestId = generateRequestId()
try {
const [workflowRecord] = await db
.select({ id: workflow.id })
.select()
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
@@ -92,6 +95,13 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
return notFoundResponse('Workflow')
}
// Clean up external webhook subscriptions before undeploying
await cleanupWebhooksForWorkflow(
workflowId,
workflowRecord as Record<string, unknown>,
requestId
)
const result = await undeployWorkflow({ workflowId })
if (!result.success) {
return internalErrorResponse(result.error || 'Failed to undeploy workflow')

View File

@@ -7,6 +7,11 @@ import { getSession } from '@/lib/auth'
import { validateInteger } from '@/lib/core/security/input-validation'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import {
cleanupExternalWebhook,
createExternalWebhookSubscription,
shouldRecreateExternalWebhookSubscription,
} from '@/lib/webhooks/provider-subscriptions'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WebhookAPI')
@@ -177,6 +182,46 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const existingProviderConfig =
(webhookData.webhook.providerConfig as Record<string, unknown>) || {}
let nextProviderConfig =
providerConfig !== undefined &&
resolvedProviderConfig &&
typeof resolvedProviderConfig === 'object'
? (resolvedProviderConfig as Record<string, unknown>)
: existingProviderConfig
const nextProvider = (provider ?? webhookData.webhook.provider) as string
if (
providerConfig !== undefined &&
shouldRecreateExternalWebhookSubscription({
previousProvider: webhookData.webhook.provider as string,
nextProvider,
previousConfig: existingProviderConfig,
nextConfig: nextProviderConfig,
})
) {
await cleanupExternalWebhook(
{ ...webhookData.webhook, providerConfig: existingProviderConfig },
webhookData.workflow,
requestId
)
const result = await createExternalWebhookSubscription(
request,
{
...webhookData.webhook,
provider: nextProvider,
providerConfig: nextProviderConfig,
},
webhookData.workflow,
session.user.id,
requestId
)
nextProviderConfig = result.updatedProviderConfig as Record<string, unknown>
}
logger.debug(`[${requestId}] Updating webhook properties`, {
hasPathUpdate: path !== undefined,
hasProviderUpdate: provider !== undefined,
@@ -188,16 +233,16 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
// Merge providerConfig to preserve credential-related fields
let finalProviderConfig = webhooks[0].webhook.providerConfig
if (providerConfig !== undefined) {
const existingConfig = (webhooks[0].webhook.providerConfig as Record<string, unknown>) || {}
const existingConfig = existingProviderConfig
finalProviderConfig = {
...resolvedProviderConfig,
...nextProviderConfig,
credentialId: existingConfig.credentialId,
credentialSetId: existingConfig.credentialSetId,
userId: existingConfig.userId,
historyId: existingConfig.historyId,
lastCheckedTimestamp: existingConfig.lastCheckedTimestamp,
setupCompleted: existingConfig.setupCompleted,
externalId: existingConfig.externalId,
externalId: nextProviderConfig.externalId ?? existingConfig.externalId,
}
}

View File

@@ -7,9 +7,8 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createExternalWebhookSubscription } from '@/lib/webhooks/provider-subscriptions'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebhooksAPI')
@@ -257,7 +256,7 @@ export async function POST(request: NextRequest) {
const finalProviderConfig = providerConfig || {}
const { resolveEnvVarsInObject } = await import('@/lib/webhooks/env-resolver')
const resolvedProviderConfig = await resolveEnvVarsInObject(
let resolvedProviderConfig = await resolveEnvVarsInObject(
finalProviderConfig,
userId,
workflowRecord.workspaceId || undefined
@@ -414,149 +413,33 @@ export async function POST(request: NextRequest) {
}
// --- End Credential Set Handling ---
// Create external subscriptions before saving to DB to prevent orphaned records
let externalSubscriptionId: string | undefined
let externalSubscriptionCreated = false
const createTempWebhookData = () => ({
const createTempWebhookData = (providerConfigOverride = resolvedProviderConfig) => ({
id: targetWebhookId || nanoid(),
path: finalPath,
providerConfig: resolvedProviderConfig,
provider,
providerConfig: providerConfigOverride,
})
if (provider === 'airtable') {
logger.info(`[${requestId}] Creating Airtable subscription before saving to database`)
try {
externalSubscriptionId = await createAirtableWebhookSubscription(
request,
userId,
createTempWebhookData(),
requestId
)
if (externalSubscriptionId) {
resolvedProviderConfig.externalId = externalSubscriptionId
externalSubscriptionCreated = true
}
} catch (err) {
logger.error(`[${requestId}] Error creating Airtable webhook subscription`, err)
return NextResponse.json(
{
error: 'Failed to create webhook in Airtable',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
if (provider === 'calendly') {
logger.info(`[${requestId}] Creating Calendly subscription before saving to database`)
try {
externalSubscriptionId = await createCalendlyWebhookSubscription(
request,
userId,
createTempWebhookData(),
requestId
)
if (externalSubscriptionId) {
resolvedProviderConfig.externalId = externalSubscriptionId
externalSubscriptionCreated = true
}
} catch (err) {
logger.error(`[${requestId}] Error creating Calendly webhook subscription`, err)
return NextResponse.json(
{
error: 'Failed to create webhook in Calendly',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
if (provider === 'microsoft-teams') {
const { createTeamsSubscription } = await import('@/lib/webhooks/provider-subscriptions')
logger.info(`[${requestId}] Creating Teams subscription before saving to database`)
try {
await createTeamsSubscription(request, createTempWebhookData(), workflowRecord, requestId)
externalSubscriptionCreated = true
} catch (err) {
logger.error(`[${requestId}] Error creating Teams subscription`, err)
return NextResponse.json(
{
error: 'Failed to create Teams subscription',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
if (provider === 'telegram') {
const { createTelegramWebhook } = await import('@/lib/webhooks/provider-subscriptions')
logger.info(`[${requestId}] Creating Telegram webhook before saving to database`)
try {
await createTelegramWebhook(request, createTempWebhookData(), requestId)
externalSubscriptionCreated = true
} catch (err) {
logger.error(`[${requestId}] Error creating Telegram webhook`, err)
return NextResponse.json(
{
error: 'Failed to create Telegram webhook',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
if (provider === 'webflow') {
logger.info(`[${requestId}] Creating Webflow subscription before saving to database`)
try {
externalSubscriptionId = await createWebflowWebhookSubscription(
request,
userId,
createTempWebhookData(),
requestId
)
if (externalSubscriptionId) {
resolvedProviderConfig.externalId = externalSubscriptionId
externalSubscriptionCreated = true
}
} catch (err) {
logger.error(`[${requestId}] Error creating Webflow webhook subscription`, err)
return NextResponse.json(
{
error: 'Failed to create webhook in Webflow',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
if (provider === 'typeform') {
const { createTypeformWebhook } = await import('@/lib/webhooks/provider-subscriptions')
logger.info(`[${requestId}] Creating Typeform webhook before saving to database`)
try {
const usedTag = await createTypeformWebhook(request, createTempWebhookData(), requestId)
if (!resolvedProviderConfig.webhookTag) {
resolvedProviderConfig.webhookTag = usedTag
logger.info(`[${requestId}] Stored auto-generated webhook tag: ${usedTag}`)
}
externalSubscriptionCreated = true
} catch (err) {
logger.error(`[${requestId}] Error creating Typeform webhook`, err)
return NextResponse.json(
{
error: 'Failed to create webhook in Typeform',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
try {
const result = await createExternalWebhookSubscription(
request,
createTempWebhookData(),
workflowRecord,
userId,
requestId
)
resolvedProviderConfig = result.updatedProviderConfig as Record<string, unknown>
externalSubscriptionCreated = result.externalSubscriptionCreated
} catch (err) {
logger.error(`[${requestId}] Error creating external webhook subscription`, err)
return NextResponse.json(
{
error: 'Failed to create external webhook subscription',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
// Now save to database (only if subscription succeeded or provider doesn't need external subscription)
@@ -617,7 +500,11 @@ export async function POST(request: NextRequest) {
logger.error(`[${requestId}] DB save failed, cleaning up external subscription`, dbError)
try {
const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions')
await cleanupExternalWebhook(createTempWebhookData(), workflowRecord, requestId)
await cleanupExternalWebhook(
createTempWebhookData(resolvedProviderConfig),
workflowRecord,
requestId
)
} catch (cleanupError) {
logger.error(
`[${requestId}] Failed to cleanup external subscription after DB save failure`,
@@ -741,110 +628,6 @@ export async function POST(request: NextRequest) {
}
// --- End RSS specific logic ---
if (savedWebhook && provider === 'grain') {
logger.info(`[${requestId}] Grain provider detected. Creating Grain webhook subscription.`)
try {
const grainResult = await createGrainWebhookSubscription(
request,
{
id: savedWebhook.id,
path: savedWebhook.path,
providerConfig: savedWebhook.providerConfig,
},
requestId
)
if (grainResult) {
// Update the webhook record with the external Grain hook ID and event types for filtering
const updatedConfig = {
...(savedWebhook.providerConfig as Record<string, any>),
externalId: grainResult.id,
eventTypes: grainResult.eventTypes,
}
await db
.update(webhook)
.set({
providerConfig: updatedConfig,
updatedAt: new Date(),
})
.where(eq(webhook.id, savedWebhook.id))
savedWebhook.providerConfig = updatedConfig
logger.info(`[${requestId}] Successfully created Grain webhook`, {
grainHookId: grainResult.id,
eventTypes: grainResult.eventTypes,
webhookId: savedWebhook.id,
})
}
} catch (err) {
logger.error(
`[${requestId}] Error creating Grain webhook subscription, rolling back webhook`,
err
)
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
return NextResponse.json(
{
error: 'Failed to create webhook in Grain',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// --- End Grain specific logic ---
// --- Lemlist specific logic ---
if (savedWebhook && provider === 'lemlist') {
logger.info(
`[${requestId}] Lemlist provider detected. Creating Lemlist webhook subscription.`
)
try {
const lemlistResult = await createLemlistWebhookSubscription(
{
id: savedWebhook.id,
path: savedWebhook.path,
providerConfig: savedWebhook.providerConfig,
},
requestId
)
if (lemlistResult) {
// Update the webhook record with the external Lemlist hook ID
const updatedConfig = {
...(savedWebhook.providerConfig as Record<string, any>),
externalId: lemlistResult.id,
}
await db
.update(webhook)
.set({
providerConfig: updatedConfig,
updatedAt: new Date(),
})
.where(eq(webhook.id, savedWebhook.id))
savedWebhook.providerConfig = updatedConfig
logger.info(`[${requestId}] Successfully created Lemlist webhook`, {
lemlistHookId: lemlistResult.id,
webhookId: savedWebhook.id,
})
}
} catch (err) {
logger.error(
`[${requestId}] Error creating Lemlist webhook subscription, rolling back webhook`,
err
)
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
return NextResponse.json(
{
error: 'Failed to create webhook in Lemlist',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// --- End Lemlist specific logic ---
if (!targetWebhookId && savedWebhook) {
try {
PlatformEvents.webhookCreated({
@@ -868,616 +651,3 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// Helper function to create the webhook subscription in Airtable
async function createAirtableWebhookSubscription(
request: NextRequest,
userId: string,
webhookData: any,
requestId: string
): Promise<string | undefined> {
try {
const { path, providerConfig } = webhookData
const { baseId, tableId, includeCellValuesInFieldIds } = providerConfig || {}
if (!baseId || !tableId) {
logger.warn(`[${requestId}] Missing baseId or tableId for Airtable webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error(
'Base ID and Table ID are required to create Airtable webhook. Please provide valid Airtable base and table IDs.'
)
}
const accessToken = await getOAuthToken(userId, 'airtable')
if (!accessToken) {
logger.warn(
`[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.`
)
throw new Error(
'Airtable account connection required. Please connect your Airtable account in the trigger configuration and try again.'
)
}
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const airtableApiUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks`
const specification: any = {
options: {
filters: {
dataTypes: ['tableData'], // Watch table data changes
recordChangeScope: tableId, // Watch only the specified table
},
},
}
// Conditionally add the 'includes' field based on the config
if (includeCellValuesInFieldIds === 'all') {
specification.options.includes = {
includeCellValuesInFieldIds: 'all',
}
}
const requestBody: any = {
notificationUrl: notificationUrl,
specification: specification,
}
const airtableResponse = await fetch(airtableApiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
// Airtable often returns 200 OK even for errors in the body, check payload
const responseBody = await airtableResponse.json()
if (!airtableResponse.ok || responseBody.error) {
const errorMessage =
responseBody.error?.message || responseBody.error || 'Unknown Airtable API error'
const errorType = responseBody.error?.type
logger.error(
`[${requestId}] Failed to create webhook in Airtable for webhook ${webhookData.id}. Status: ${airtableResponse.status}`,
{ type: errorType, message: errorMessage, response: responseBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Airtable'
if (airtableResponse.status === 404) {
userFriendlyMessage =
'Airtable base or table not found. Please verify that the Base ID and Table ID are correct and that you have access to them.'
} else if (errorMessage && errorMessage !== 'Unknown Airtable API error') {
userFriendlyMessage = `Airtable error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
logger.info(
`[${requestId}] Successfully created webhook in Airtable for webhook ${webhookData.id}.`,
{
airtableWebhookId: responseBody.id,
}
)
return responseBody.id
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Airtable webhook creation for webhook ${webhookData.id}.`,
{
message: error.message,
stack: error.stack,
}
)
// Re-throw the error so it can be caught by the outer try-catch
throw error
}
}
// Helper function to create the webhook subscription in Calendly
async function createCalendlyWebhookSubscription(
request: NextRequest,
userId: string,
webhookData: any,
requestId: string
): Promise<string | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, organization, triggerId } = providerConfig || {}
if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Calendly webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error(
'Personal Access Token is required to create Calendly webhook. Please provide your Calendly Personal Access Token.'
)
}
if (!organization) {
logger.warn(`[${requestId}] Missing organization URI for Calendly webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error(
'Organization URI is required to create Calendly webhook. Please provide your Organization URI from the "Get Current User" operation.'
)
}
if (!triggerId) {
logger.warn(`[${requestId}] Missing triggerId for Calendly webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error('Trigger ID is required to create Calendly webhook')
}
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
// Map trigger IDs to Calendly event types
const eventTypeMap: Record<string, string[]> = {
calendly_invitee_created: ['invitee.created'],
calendly_invitee_canceled: ['invitee.canceled'],
calendly_routing_form_submitted: ['routing_form_submission.created'],
calendly_webhook: ['invitee.created', 'invitee.canceled', 'routing_form_submission.created'],
}
const events = eventTypeMap[triggerId] || ['invitee.created']
const calendlyApiUrl = 'https://api.calendly.com/webhook_subscriptions'
const requestBody = {
url: notificationUrl,
events,
organization,
scope: 'organization',
}
const calendlyResponse = await fetch(calendlyApiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
if (!calendlyResponse.ok) {
const errorBody = await calendlyResponse.json().catch(() => ({}))
const errorMessage = errorBody.message || errorBody.title || 'Unknown Calendly API error'
logger.error(
`[${requestId}] Failed to create webhook in Calendly for webhook ${webhookData.id}. Status: ${calendlyResponse.status}`,
{ response: errorBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Calendly'
if (calendlyResponse.status === 401) {
userFriendlyMessage =
'Calendly authentication failed. Please verify your Personal Access Token is correct.'
} else if (calendlyResponse.status === 403) {
userFriendlyMessage =
'Calendly access denied. Please ensure you have appropriate permissions and a paid Calendly subscription.'
} else if (calendlyResponse.status === 404) {
userFriendlyMessage =
'Calendly organization not found. Please verify the Organization URI is correct.'
} else if (errorMessage && errorMessage !== 'Unknown Calendly API error') {
userFriendlyMessage = `Calendly error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
const responseBody = await calendlyResponse.json()
const webhookUri = responseBody.resource?.uri
if (!webhookUri) {
logger.error(
`[${requestId}] Calendly webhook created but no webhook URI returned for webhook ${webhookData.id}`,
{ response: responseBody }
)
throw new Error('Calendly webhook creation succeeded but no webhook URI was returned')
}
// Extract the webhook ID from the URI (e.g., https://api.calendly.com/webhook_subscriptions/WEBHOOK_ID)
const webhookId = webhookUri.split('/').pop()
if (!webhookId) {
logger.error(`[${requestId}] Could not extract webhook ID from Calendly URI: ${webhookUri}`, {
response: responseBody,
})
throw new Error('Failed to extract webhook ID from Calendly response')
}
logger.info(
`[${requestId}] Successfully created webhook in Calendly for webhook ${webhookData.id}.`,
{
calendlyWebhookUri: webhookUri,
calendlyWebhookId: webhookId,
}
)
return webhookId
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Calendly webhook creation for webhook ${webhookData.id}.`,
{
message: error.message,
stack: error.stack,
}
)
// Re-throw the error so it can be caught by the outer try-catch
throw error
}
}
// Helper function to create the webhook subscription in Webflow
async function createWebflowWebhookSubscription(
request: NextRequest,
userId: string,
webhookData: any,
requestId: string
): Promise<string | undefined> {
try {
const { path, providerConfig } = webhookData
const { siteId, triggerId, collectionId, formId } = providerConfig || {}
if (!siteId) {
logger.warn(`[${requestId}] Missing siteId for Webflow webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error('Site ID is required to create Webflow webhook')
}
if (!triggerId) {
logger.warn(`[${requestId}] Missing triggerId for Webflow webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error('Trigger type is required to create Webflow webhook')
}
const accessToken = await getOAuthToken(userId, 'webflow')
if (!accessToken) {
logger.warn(
`[${requestId}] Could not retrieve Webflow access token for user ${userId}. Cannot create webhook in Webflow.`
)
throw new Error(
'Webflow account connection required. Please connect your Webflow account in the trigger configuration and try again.'
)
}
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
// Map trigger IDs to Webflow trigger types
const triggerTypeMap: Record<string, string> = {
webflow_collection_item_created: 'collection_item_created',
webflow_collection_item_changed: 'collection_item_changed',
webflow_collection_item_deleted: 'collection_item_deleted',
webflow_form_submission: 'form_submission',
}
const webflowTriggerType = triggerTypeMap[triggerId]
if (!webflowTriggerType) {
logger.warn(`[${requestId}] Invalid triggerId for Webflow: ${triggerId}`, {
webhookId: webhookData.id,
})
throw new Error(`Invalid Webflow trigger type: ${triggerId}`)
}
const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks`
const requestBody: any = {
triggerType: webflowTriggerType,
url: notificationUrl,
}
// Add filter for collection-based triggers
if (collectionId && webflowTriggerType.startsWith('collection_item_')) {
requestBody.filter = {
resource_type: 'collection',
resource_id: collectionId,
}
}
// Add filter for form submissions
if (formId && webflowTriggerType === 'form_submission') {
requestBody.filter = {
resource_type: 'form',
resource_id: formId,
}
}
const webflowResponse = await fetch(webflowApiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = await webflowResponse.json()
if (!webflowResponse.ok || responseBody.error) {
const errorMessage = responseBody.message || responseBody.error || 'Unknown Webflow API error'
logger.error(
`[${requestId}] Failed to create webhook in Webflow for webhook ${webhookData.id}. Status: ${webflowResponse.status}`,
{ message: errorMessage, response: responseBody }
)
throw new Error(errorMessage)
}
logger.info(
`[${requestId}] Successfully created webhook in Webflow for webhook ${webhookData.id}.`,
{
webflowWebhookId: responseBody.id || responseBody._id,
}
)
return responseBody.id || responseBody._id
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Webflow webhook creation for webhook ${webhookData.id}.`,
{
message: error.message,
stack: error.stack,
}
)
throw error
}
}
// Helper function to create the webhook subscription in Grain
async function createGrainWebhookSubscription(
request: NextRequest,
webhookData: any,
requestId: string
): Promise<{ id: string; eventTypes: string[] } | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, triggerId, includeHighlights, includeParticipants, includeAiSummary } =
providerConfig || {}
if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error(
'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.'
)
}
// Map trigger IDs to Grain API hook_type (only 2 options: recording_added, upload_status)
const hookTypeMap: Record<string, string> = {
grain_webhook: 'recording_added',
grain_recording_created: 'recording_added',
grain_recording_updated: 'recording_added',
grain_highlight_created: 'recording_added',
grain_highlight_updated: 'recording_added',
grain_story_created: 'recording_added',
grain_upload_status: 'upload_status',
}
const eventTypeMap: Record<string, string[]> = {
grain_webhook: [],
grain_recording_created: ['recording_added'],
grain_recording_updated: ['recording_updated'],
grain_highlight_created: ['highlight_created'],
grain_highlight_updated: ['highlight_updated'],
grain_story_created: ['story_created'],
grain_upload_status: ['upload_status'],
}
const hookType = hookTypeMap[triggerId] ?? 'recording_added'
const eventTypes = eventTypeMap[triggerId] ?? []
if (!hookTypeMap[triggerId]) {
logger.warn(
`[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to recording_added`,
{
webhookId: webhookData.id,
}
)
}
logger.info(`[${requestId}] Creating Grain webhook`, {
triggerId,
hookType,
eventTypes,
webhookId: webhookData.id,
})
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const grainApiUrl = 'https://api.grain.com/_/public-api/v2/hooks/create'
const requestBody: Record<string, any> = {
hook_url: notificationUrl,
hook_type: hookType,
}
// Build include object based on configuration
const include: Record<string, boolean> = {}
if (includeHighlights) {
include.highlights = true
}
if (includeParticipants) {
include.participants = true
}
if (includeAiSummary) {
include.ai_summary = true
}
if (Object.keys(include).length > 0) {
requestBody.include = include
}
const grainResponse = await fetch(grainApiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Public-Api-Version': '2025-10-31',
},
body: JSON.stringify(requestBody),
})
const responseBody = await grainResponse.json()
if (!grainResponse.ok || responseBody.error || responseBody.errors) {
logger.warn('[App] Grain response body:', responseBody)
const errorMessage =
responseBody.errors?.detail ||
responseBody.error?.message ||
responseBody.error ||
responseBody.message ||
'Unknown Grain API error'
logger.error(
`[${requestId}] Failed to create webhook in Grain for webhook ${webhookData.id}. Status: ${grainResponse.status}`,
{ message: errorMessage, response: responseBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Grain'
if (grainResponse.status === 401) {
userFriendlyMessage =
'Invalid Grain API Key. Please verify your Personal Access Token is correct.'
} else if (grainResponse.status === 403) {
userFriendlyMessage =
'Access denied. Please ensure your Grain API Key has appropriate permissions.'
} else if (errorMessage && errorMessage !== 'Unknown Grain API error') {
userFriendlyMessage = `Grain error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
logger.info(
`[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`,
{
grainWebhookId: responseBody.id,
eventTypes,
}
)
return { id: responseBody.id, eventTypes }
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`,
{
message: error.message,
stack: error.stack,
}
)
throw error
}
}
// Helper function to create the webhook subscription in Lemlist
async function createLemlistWebhookSubscription(
webhookData: any,
requestId: string
): Promise<{ id: string } | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, triggerId, campaignId } = providerConfig || {}
if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Lemlist webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error(
'Lemlist API Key is required. Please provide your Lemlist API Key in the trigger configuration.'
)
}
// Map trigger IDs to Lemlist event types
const eventTypeMap: Record<string, string | undefined> = {
lemlist_email_replied: 'emailsReplied',
lemlist_linkedin_replied: 'linkedinReplied',
lemlist_interested: 'interested',
lemlist_not_interested: 'notInterested',
lemlist_email_opened: 'emailsOpened',
lemlist_email_clicked: 'emailsClicked',
lemlist_email_bounced: 'emailsBounced',
lemlist_email_sent: 'emailsSent',
lemlist_webhook: undefined, // Generic webhook - no type filter
}
const eventType = eventTypeMap[triggerId]
logger.info(`[${requestId}] Creating Lemlist webhook`, {
triggerId,
eventType,
hasCampaignId: !!campaignId,
webhookId: webhookData.id,
})
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const lemlistApiUrl = 'https://api.lemlist.com/api/hooks'
// Build request body
const requestBody: Record<string, any> = {
targetUrl: notificationUrl,
}
// Add event type if specified (omit for generic webhook to receive all events)
if (eventType) {
requestBody.type = eventType
}
// Add campaign filter if specified
if (campaignId) {
requestBody.campaignId = campaignId
}
// Lemlist uses Basic Auth with empty username and API key as password
const authString = Buffer.from(`:${apiKey}`).toString('base64')
const lemlistResponse = await fetch(lemlistApiUrl, {
method: 'POST',
headers: {
Authorization: `Basic ${authString}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = await lemlistResponse.json()
if (!lemlistResponse.ok || responseBody.error) {
const errorMessage = responseBody.message || responseBody.error || 'Unknown Lemlist API error'
logger.error(
`[${requestId}] Failed to create webhook in Lemlist for webhook ${webhookData.id}. Status: ${lemlistResponse.status}`,
{ message: errorMessage, response: responseBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Lemlist'
if (lemlistResponse.status === 401) {
userFriendlyMessage = 'Invalid Lemlist API Key. Please verify your API Key is correct.'
} else if (lemlistResponse.status === 403) {
userFriendlyMessage =
'Access denied. Please ensure your Lemlist API Key has appropriate permissions.'
} else if (errorMessage && errorMessage !== 'Unknown Lemlist API error') {
userFriendlyMessage = `Lemlist error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
logger.info(
`[${requestId}] Successfully created webhook in Lemlist for webhook ${webhookData.id}.`,
{
lemlistWebhookId: responseBody._id,
}
)
return { id: responseBody._id }
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Lemlist webhook creation for webhook ${webhookData.id}.`,
{
message: error.message,
stack: error.stack,
}
)
throw error
}
}

View File

@@ -3,15 +3,92 @@
*
* @vitest-environment node
*/
import { loggerMock } from '@sim/testing'
import { createMockRequest, loggerMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
globalMockData,
mockExecutionDependencies,
mockTriggerDevSdk,
} from '@/app/api/__test-utils__/utils'
/** Mock execution dependencies for webhook tests */
function mockExecutionDependencies() {
vi.mock('@/lib/core/security/encryption', () => ({
decryptSecret: vi.fn().mockResolvedValue({ decrypted: 'decrypted-value' }),
}))
vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({
buildTraceSpans: vi.fn().mockReturnValue({ traceSpans: [], totalDuration: 100 }),
}))
vi.mock('@/lib/workflows/utils', () => ({
updateWorkflowRunCounts: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/serializer', () => ({
Serializer: vi.fn().mockImplementation(() => ({
serializeWorkflow: vi.fn().mockReturnValue({
version: '1.0',
blocks: [
{
id: 'starter-id',
metadata: { id: 'starter', name: 'Start' },
config: {},
inputs: {},
outputs: {},
position: { x: 100, y: 100 },
enabled: true,
},
{
id: 'agent-id',
metadata: { id: 'agent', name: 'Agent 1' },
config: {},
inputs: {},
outputs: {},
position: { x: 634, y: -167 },
enabled: true,
},
],
edges: [
{
id: 'edge-1',
source: 'starter-id',
target: 'agent-id',
sourceHandle: 'source',
targetHandle: 'target',
},
],
loops: {},
parallels: {},
}),
})),
}))
}
/** Mock Trigger.dev SDK */
function mockTriggerDevSdk() {
vi.mock('@trigger.dev/sdk', () => ({
tasks: { trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }) },
task: vi.fn().mockReturnValue({}),
}))
}
/**
* Test data store - isolated per test via beforeEach reset
* This replaces the global mutable state pattern with local test data
*/
const testData = {
webhooks: [] as Array<{
id: string
provider: string
path: string
isActive: boolean
providerConfig?: Record<string, unknown>
workflowId: string
rateLimitCount?: number
rateLimitPeriod?: number
}>,
workflows: [] as Array<{
id: string
userId: string
workspaceId?: string
}>,
}
const {
generateRequestHashMock,
@@ -159,8 +236,8 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({
vi.mock('@/lib/webhooks/processor', () => ({
findAllWebhooksForPath: vi.fn().mockImplementation(async (options: { path: string }) => {
// Filter webhooks by path from globalMockData
const matchingWebhooks = globalMockData.webhooks.filter(
// Filter webhooks by path from testData
const matchingWebhooks = testData.webhooks.filter(
(wh) => wh.path === options.path && wh.isActive
)
@@ -170,7 +247,7 @@ vi.mock('@/lib/webhooks/processor', () => ({
// Return array of {webhook, workflow} objects
return matchingWebhooks.map((wh) => {
const matchingWorkflow = globalMockData.workflows.find((w) => w.id === wh.workflowId) || {
const matchingWorkflow = testData.workflows.find((w) => w.id === wh.workflowId) || {
id: wh.workflowId || 'test-workflow-id',
userId: 'test-user-id',
workspaceId: 'test-workspace-id',
@@ -283,14 +360,15 @@ describe('Webhook Trigger API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
globalMockData.webhooks.length = 0
globalMockData.workflows.length = 0
globalMockData.schedules.length = 0
// Reset test data arrays
testData.webhooks.length = 0
testData.workflows.length = 0
mockExecutionDependencies()
mockTriggerDevSdk()
globalMockData.workflows.push({
// Set up default workflow for tests
testData.workflows.push({
id: 'test-workflow-id',
userId: 'test-user-id',
workspaceId: 'test-workspace-id',
@@ -326,7 +404,7 @@ describe('Webhook Trigger API Route', () => {
describe('Generic Webhook Authentication', () => {
it('should process generic webhook without authentication', async () => {
globalMockData.webhooks.push({
testData.webhooks.push({
id: 'generic-webhook-id',
provider: 'generic',
path: 'test-path',
@@ -336,7 +414,7 @@ describe('Webhook Trigger API Route', () => {
rateLimitCount: 100,
rateLimitPeriod: 60,
})
globalMockData.workflows.push({
testData.workflows.push({
id: 'test-workflow-id',
userId: 'test-user-id',
workspaceId: 'test-workspace-id',
@@ -354,7 +432,7 @@ describe('Webhook Trigger API Route', () => {
})
it('should authenticate with Bearer token when no custom header is configured', async () => {
globalMockData.webhooks.push({
testData.webhooks.push({
id: 'generic-webhook-id',
provider: 'generic',
path: 'test-path',
@@ -362,7 +440,7 @@ describe('Webhook Trigger API Route', () => {
providerConfig: { requireAuth: true, token: 'test-token-123' },
workflowId: 'test-workflow-id',
})
globalMockData.workflows.push({
testData.workflows.push({
id: 'test-workflow-id',
userId: 'test-user-id',
workspaceId: 'test-workspace-id',
@@ -381,7 +459,7 @@ describe('Webhook Trigger API Route', () => {
})
it('should authenticate with custom header when configured', async () => {
globalMockData.webhooks.push({
testData.webhooks.push({
id: 'generic-webhook-id',
provider: 'generic',
path: 'test-path',
@@ -393,7 +471,7 @@ describe('Webhook Trigger API Route', () => {
},
workflowId: 'test-workflow-id',
})
globalMockData.workflows.push({
testData.workflows.push({
id: 'test-workflow-id',
userId: 'test-user-id',
workspaceId: 'test-workspace-id',
@@ -412,7 +490,7 @@ describe('Webhook Trigger API Route', () => {
})
it('should handle case insensitive Bearer token authentication', async () => {
globalMockData.webhooks.push({
testData.webhooks.push({
id: 'generic-webhook-id',
provider: 'generic',
path: 'test-path',
@@ -420,7 +498,7 @@ describe('Webhook Trigger API Route', () => {
providerConfig: { requireAuth: true, token: 'case-test-token' },
workflowId: 'test-workflow-id',
})
globalMockData.workflows.push({
testData.workflows.push({
id: 'test-workflow-id',
userId: 'test-user-id',
workspaceId: 'test-workspace-id',
@@ -454,7 +532,7 @@ describe('Webhook Trigger API Route', () => {
})
it('should handle case insensitive custom header authentication', async () => {
globalMockData.webhooks.push({
testData.webhooks.push({
id: 'generic-webhook-id',
provider: 'generic',
path: 'test-path',
@@ -466,7 +544,7 @@ describe('Webhook Trigger API Route', () => {
},
workflowId: 'test-workflow-id',
})
globalMockData.workflows.push({
testData.workflows.push({
id: 'test-workflow-id',
userId: 'test-user-id',
workspaceId: 'test-workspace-id',
@@ -495,7 +573,7 @@ describe('Webhook Trigger API Route', () => {
})
it('should reject wrong Bearer token', async () => {
globalMockData.webhooks.push({
testData.webhooks.push({
id: 'generic-webhook-id',
provider: 'generic',
path: 'test-path',
@@ -519,7 +597,7 @@ describe('Webhook Trigger API Route', () => {
})
it('should reject wrong custom header token', async () => {
globalMockData.webhooks.push({
testData.webhooks.push({
id: 'generic-webhook-id',
provider: 'generic',
path: 'test-path',
@@ -547,7 +625,7 @@ describe('Webhook Trigger API Route', () => {
})
it('should reject missing authentication when required', async () => {
globalMockData.webhooks.push({
testData.webhooks.push({
id: 'generic-webhook-id',
provider: 'generic',
path: 'test-path',
@@ -567,7 +645,7 @@ describe('Webhook Trigger API Route', () => {
})
it('should reject Bearer token when custom header is configured', async () => {
globalMockData.webhooks.push({
testData.webhooks.push({
id: 'generic-webhook-id',
provider: 'generic',
path: 'test-path',
@@ -595,7 +673,7 @@ describe('Webhook Trigger API Route', () => {
})
it('should reject wrong custom header name', async () => {
globalMockData.webhooks.push({
testData.webhooks.push({
id: 'generic-webhook-id',
provider: 'generic',
path: 'test-path',
@@ -623,7 +701,7 @@ describe('Webhook Trigger API Route', () => {
})
it('should reject when auth is required but no token is configured', async () => {
globalMockData.webhooks.push({
testData.webhooks.push({
id: 'generic-webhook-id',
provider: 'generic',
path: 'test-path',
@@ -631,7 +709,7 @@ describe('Webhook Trigger API Route', () => {
providerConfig: { requireAuth: true },
workflowId: 'test-workflow-id',
})
globalMockData.workflows.push({ id: 'test-workflow-id', userId: 'test-user-id' })
testData.workflows.push({ id: 'test-workflow-id', userId: 'test-user-id' })
const headers = {
'Content-Type': 'application/json',

View File

@@ -4,6 +4,7 @@ import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { cleanupWebhooksForWorkflow, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import {
deployWorkflow,
loadWorkflowFromNormalizedTables,
@@ -130,6 +131,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId: id,
workflow: workflowData,
userId: actorUserId,
blocks: normalizedData.blocks,
requestId,
})
if (!triggerSaveResult.success) {
return createErrorResponse(
triggerSaveResult.error?.message || 'Failed to save trigger configuration',
triggerSaveResult.error?.status || 500
)
}
const deployResult = await deployWorkflow({
workflowId: id,
deployedBy: actorUserId,
@@ -202,11 +219,18 @@ export async function DELETE(
try {
logger.debug(`[${requestId}] Undeploying workflow: ${id}`)
const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
const { error, workflow: workflowData } = await validateWorkflowPermissions(
id,
requestId,
'admin'
)
if (error) {
return createErrorResponse(error.message, error.status)
}
// Clean up external webhook subscriptions before undeploying
await cleanupWebhooksForWorkflow(id, workflowData as Record<string, unknown>, requestId)
const result = await undeployWorkflow({ workflowId: id })
if (!result.success) {
return createErrorResponse(result.error || 'Failed to undeploy workflow', 500)

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { webhook, workflow } from '@sim/db/schema'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
@@ -13,7 +13,6 @@ import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validat
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { getTrigger } from '@/triggers'
const logger = createLogger('WorkflowStateAPI')
@@ -203,8 +202,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
)
}
await syncWorkflowWebhooks(workflowId, workflowState.blocks)
// Extract and persist custom tools to database
try {
const workspaceId = workflowData.workspaceId
@@ -290,213 +287,3 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
function getSubBlockValue<T = unknown>(block: BlockState, subBlockId: string): T | undefined {
const value = block.subBlocks?.[subBlockId]?.value
if (value === undefined || value === null) {
return undefined
}
return value as T
}
async function syncWorkflowWebhooks(
workflowId: string,
blocks: Record<string, any>
): Promise<void> {
await syncBlockResources(workflowId, blocks, {
resourceName: 'webhook',
subBlockId: 'webhookId',
buildMetadata: buildWebhookMetadata,
applyMetadata: upsertWebhookRecord,
})
}
interface WebhookMetadata {
triggerPath: string
provider: string | null
providerConfig: Record<string, any>
}
const CREDENTIAL_SET_PREFIX = 'credentialSet:'
function buildWebhookMetadata(block: BlockState): WebhookMetadata | null {
const triggerId =
getSubBlockValue<string>(block, 'triggerId') ||
getSubBlockValue<string>(block, 'selectedTriggerId')
const triggerConfig = getSubBlockValue<Record<string, any>>(block, 'triggerConfig') || {}
const triggerCredentials = getSubBlockValue<string>(block, 'triggerCredentials')
const triggerPath = getSubBlockValue<string>(block, 'triggerPath') || block.id
const triggerDef = triggerId ? getTrigger(triggerId) : undefined
const provider = triggerDef?.provider || null
// Handle credential sets vs individual credentials
const isCredentialSet = triggerCredentials?.startsWith(CREDENTIAL_SET_PREFIX)
const credentialSetId = isCredentialSet
? triggerCredentials!.slice(CREDENTIAL_SET_PREFIX.length)
: undefined
const credentialId = isCredentialSet ? undefined : triggerCredentials
const providerConfig = {
...(typeof triggerConfig === 'object' ? triggerConfig : {}),
...(credentialId ? { credentialId } : {}),
...(credentialSetId ? { credentialSetId } : {}),
...(triggerId ? { triggerId } : {}),
}
return {
triggerPath,
provider,
providerConfig,
}
}
async function upsertWebhookRecord(
workflowId: string,
block: BlockState,
webhookId: string,
metadata: WebhookMetadata
): Promise<void> {
const providerConfig = metadata.providerConfig as Record<string, unknown>
const credentialSetId = providerConfig?.credentialSetId as string | undefined
// For credential sets, delegate to the sync function which handles fan-out
if (credentialSetId && metadata.provider) {
const { syncWebhooksForCredentialSet } = await import('@/lib/webhooks/utils.server')
const { getProviderIdFromServiceId } = await import('@/lib/oauth')
const oauthProviderId = getProviderIdFromServiceId(metadata.provider)
const requestId = crypto.randomUUID().slice(0, 8)
// Extract base config (without credential-specific fields)
const {
credentialId: _cId,
credentialSetId: _csId,
userId: _uId,
...baseConfig
} = providerConfig
try {
await syncWebhooksForCredentialSet({
workflowId,
blockId: block.id,
provider: metadata.provider,
basePath: metadata.triggerPath,
credentialSetId,
oauthProviderId,
providerConfig: baseConfig as Record<string, any>,
requestId,
})
logger.info('Synced credential set webhooks during workflow save', {
workflowId,
blockId: block.id,
credentialSetId,
})
} catch (error) {
logger.error('Failed to sync credential set webhooks during workflow save', {
workflowId,
blockId: block.id,
credentialSetId,
error,
})
}
return
}
// For individual credentials, use the existing single webhook logic
const [existing] = await db.select().from(webhook).where(eq(webhook.id, webhookId)).limit(1)
if (existing) {
const needsUpdate =
existing.blockId !== block.id ||
existing.workflowId !== workflowId ||
existing.path !== metadata.triggerPath
if (needsUpdate) {
await db
.update(webhook)
.set({
workflowId,
blockId: block.id,
path: metadata.triggerPath,
provider: metadata.provider || existing.provider,
providerConfig: Object.keys(metadata.providerConfig).length
? metadata.providerConfig
: existing.providerConfig,
isActive: true,
updatedAt: new Date(),
})
.where(eq(webhook.id, webhookId))
}
return
}
await db.insert(webhook).values({
id: webhookId,
workflowId,
blockId: block.id,
path: metadata.triggerPath,
provider: metadata.provider,
providerConfig: metadata.providerConfig,
credentialSetId: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
})
logger.info('Recreated missing webhook after workflow save', {
workflowId,
blockId: block.id,
webhookId,
})
}
interface BlockResourceSyncConfig<T> {
resourceName: string
subBlockId: string
buildMetadata: (block: BlockState, resourceId: string) => T | null
applyMetadata: (
workflowId: string,
block: BlockState,
resourceId: string,
metadata: T
) => Promise<void>
}
async function syncBlockResources<T>(
workflowId: string,
blocks: Record<string, any>,
config: BlockResourceSyncConfig<T>
): Promise<void> {
const blockEntries = Object.values(blocks || {}).filter(Boolean) as BlockState[]
if (blockEntries.length === 0) return
for (const block of blockEntries) {
const resourceId = getSubBlockValue<string>(block, config.subBlockId)
if (!resourceId) continue
const metadata = config.buildMetadata(block, resourceId)
if (!metadata) {
logger.warn(`Skipping ${config.resourceName} sync due to invalid configuration`, {
workflowId,
blockId: block.id,
resourceId,
resourceName: config.resourceName,
})
continue
}
try {
await config.applyMetadata(workflowId, block, resourceId, metadata)
} catch (error) {
logger.error(`Failed to sync ${config.resourceName}`, {
workflowId,
blockId: block.id,
resourceId,
resourceName: config.resourceName,
error,
})
}
}
}

View File

@@ -4,29 +4,29 @@
*
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockDatabase,
databaseMock,
defaultMockUser,
mockAuth,
mockCryptoUuid,
mockUser,
setupCommonApiMocks,
} from '@/app/api/__test-utils__/utils'
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('Workflow Variables API Route', () => {
let authMocks: ReturnType<typeof mockAuth>
let databaseMocks: ReturnType<typeof createMockDatabase>
const mockGetWorkflowAccessContext = vi.fn()
beforeEach(() => {
vi.resetModules()
setupCommonApiMocks()
mockCryptoUuid('mock-request-id-12345678')
authMocks = mockAuth(mockUser)
authMocks = mockAuth(defaultMockUser)
mockGetWorkflowAccessContext.mockReset()
vi.doMock('@sim/db', () => databaseMock)
vi.doMock('@/lib/workflows/utils', () => ({
getWorkflowAccessContext: mockGetWorkflowAccessContext,
}))
@@ -203,10 +203,6 @@ describe('Workflow Variables API Route', () => {
isWorkspaceOwner: false,
})
databaseMocks = createMockDatabase({
update: { results: [{}] },
})
const variables = {
'var-1': {
id: 'var-1',

View File

@@ -1,5 +1,5 @@
import { createMockRequest, mockAuth, mockConsoleLogger } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, mockAuth, mockConsoleLogger } from '@/app/api/__test-utils__/utils'
describe('Workspace Invitations API Route', () => {
const mockWorkspace = { id: 'workspace-1', name: 'Test Workspace' }

View File

@@ -12,6 +12,7 @@ import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler'
import { QueryProvider } from '@/app/_shell/providers/query-provider'
import { SessionProvider } from '@/app/_shell/providers/session-provider'
import { ThemeProvider } from '@/app/_shell/providers/theme-provider'
import { TooltipProvider } from '@/app/_shell/providers/tooltip-provider'
import { season } from '@/app/_styles/fonts/season/season'
export const viewport: Viewport = {
@@ -208,7 +209,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<ThemeProvider>
<QueryProvider>
<SessionProvider>
<BrandedLayout>{children}</BrandedLayout>
<TooltipProvider>
<BrandedLayout>{children}</BrandedLayout>
</TooltipProvider>
</SessionProvider>
</QueryProvider>
</ThemeProvider>

View File

@@ -21,12 +21,15 @@ import {
Combobox,
Connections,
Copy,
Cursor,
DatePicker,
DocumentAttachment,
Duplicate,
Expand,
Eye,
FolderCode,
FolderPlus,
Hand,
HexSimple,
Input,
Key as KeyIcon,
@@ -991,11 +994,14 @@ export default function PlaygroundPage() {
{ Icon: ChevronDown, name: 'ChevronDown' },
{ Icon: Connections, name: 'Connections' },
{ Icon: Copy, name: 'Copy' },
{ Icon: Cursor, name: 'Cursor' },
{ Icon: DocumentAttachment, name: 'DocumentAttachment' },
{ Icon: Duplicate, name: 'Duplicate' },
{ Icon: Expand, name: 'Expand' },
{ Icon: Eye, name: 'Eye' },
{ Icon: FolderCode, name: 'FolderCode' },
{ Icon: FolderPlus, name: 'FolderPlus' },
{ Icon: Hand, name: 'Hand' },
{ Icon: HexSimple, name: 'HexSimple' },
{ Icon: KeyIcon, name: 'Key' },
{ Icon: Layout, name: 'Layout' },

View File

@@ -1,15 +1,12 @@
'use client'
import { Tooltip } from '@/components/emcn'
import { season } from '@/app/_styles/fonts/season/season'
export default function TemplatesLayoutClient({ children }: { children: React.ReactNode }) {
return (
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<div className={`${season.variable} relative flex min-h-screen flex-col font-season`}>
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
{children}
</div>
</Tooltip.Provider>
<div className={`${season.variable} relative flex min-h-screen flex-col font-season`}>
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
{children}
</div>
)
}

View File

@@ -1,6 +1,5 @@
'use client'
import { Tooltip } from '@/components/emcn'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
@@ -13,16 +12,14 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
<SettingsLoader />
<ProviderModelsLoader />
<GlobalCommandsProvider>
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<div className='flex h-screen w-full bg-[var(--bg)]'>
<WorkspacePermissionsProvider>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />
</div>
{children}
</WorkspacePermissionsProvider>
</div>
</Tooltip.Provider>
<div className='flex h-screen w-full bg-[var(--bg)]'>
<WorkspacePermissionsProvider>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />
</div>
{children}
</WorkspacePermissionsProvider>
</div>
</GlobalCommandsProvider>
</>
)

View File

@@ -19,6 +19,7 @@ export type CommandId =
| 'clear-terminal-console'
| 'focus-toolbar-search'
| 'clear-notifications'
| 'fit-to-view'
/**
* Static metadata for a global command.
@@ -104,6 +105,11 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
shortcut: 'Mod+E',
allowInEditable: false,
},
'fit-to-view': {
id: 'fit-to-view',
shortcut: 'Mod+Shift+F',
allowInEditable: false,
},
}
/**

View File

@@ -1,5 +1,6 @@
'use client'
import type { RefObject } from 'react'
import {
Popover,
PopoverAnchor,
@@ -7,14 +8,48 @@ import {
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { BlockContextMenuProps } from './types'
/**
* Block information for context menu actions
*/
export interface BlockInfo {
id: string
type: string
enabled: boolean
horizontalHandles: boolean
parentId?: string
parentType?: string
}
/**
* Props for BlockMenu component
*/
export interface BlockMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
selectedBlocks: BlockInfo[]
onCopy: () => void
onPaste: () => void
onDuplicate: () => void
onDelete: () => void
onToggleEnabled: () => void
onToggleHandles: () => void
onRemoveFromSubflow: () => void
onOpenEditor: () => void
onRename: () => void
hasClipboard?: boolean
showRemoveFromSubflow?: boolean
disableEdit?: boolean
}
/**
* Context menu for workflow block(s).
* Displays block-specific actions in a popover at right-click position.
* Supports multi-selection - actions apply to all selected blocks.
*/
export function BlockContextMenu({
export function BlockMenu({
isOpen,
position,
menuRef,
@@ -32,7 +67,7 @@ export function BlockContextMenu({
hasClipboard = false,
showRemoveFromSubflow = false,
disableEdit = false,
}: BlockContextMenuProps) {
}: BlockMenuProps) {
const isSingleBlock = selectedBlocks.length === 1
const allEnabled = selectedBlocks.every((b) => b.enabled)

View File

@@ -0,0 +1,2 @@
export type { BlockInfo, BlockMenuProps } from './block-menu'
export { BlockMenu } from './block-menu'

View File

@@ -1,5 +1,6 @@
'use client'
import type { RefObject } from 'react'
import {
Popover,
PopoverAnchor,
@@ -7,13 +8,40 @@ import {
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { PaneContextMenuProps } from './types'
/**
* Context menu for workflow canvas pane.
* Props for CanvasMenu component
*/
export interface CanvasMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
onUndo: () => void
onRedo: () => void
onPaste: () => void
onAddBlock: () => void
onAutoLayout: () => void
onFitToView: () => void
onOpenLogs: () => void
onToggleVariables: () => void
onToggleChat: () => void
onInvite: () => void
isVariablesOpen?: boolean
isChatOpen?: boolean
hasClipboard?: boolean
disableEdit?: boolean
disableAdmin?: boolean
canUndo?: boolean
canRedo?: boolean
isInvitationsDisabled?: boolean
}
/**
* Context menu for workflow canvas.
* Displays canvas-level actions when right-clicking empty space.
*/
export function PaneContextMenu({
export function CanvasMenu({
isOpen,
position,
menuRef,
@@ -23,6 +51,7 @@ export function PaneContextMenu({
onPaste,
onAddBlock,
onAutoLayout,
onFitToView,
onOpenLogs,
onToggleVariables,
onToggleChat,
@@ -35,7 +64,7 @@ export function PaneContextMenu({
canUndo = false,
canRedo = false,
isInvitationsDisabled = false,
}: PaneContextMenuProps) {
}: CanvasMenuProps) {
return (
<Popover
open={isOpen}
@@ -113,6 +142,14 @@ export function PaneContextMenu({
<span>Auto-layout</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>L</span>
</PopoverItem>
<PopoverItem
onClick={() => {
onFitToView()
onClose()
}}
>
Fit to View
</PopoverItem>
{/* Navigation actions */}
<PopoverDivider />

View File

@@ -0,0 +1,2 @@
export type { CanvasMenuProps } from './canvas-menu'
export { CanvasMenu } from './canvas-menu'

View File

@@ -20,6 +20,7 @@ import {
PopoverItem,
PopoverScrollArea,
PopoverTrigger,
Tooltip,
Trash,
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
@@ -29,7 +30,7 @@ import {
extractPathFromOutputId,
parseOutputContentSafely,
} from '@/lib/core/utils/response-format'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { START_BLOCK_RESERVED_FIELDS } from '@/lib/workflows/types'
import {
@@ -869,7 +870,7 @@ export function Chat() {
<div className='flex flex-shrink-0 items-center gap-[8px]'>
{/* More menu with actions */}
<Popover variant='default' open={moreMenuOpen} onOpenChange={setMoreMenuOpen}>
<Popover variant='default' size='sm' open={moreMenuOpen} onOpenChange={setMoreMenuOpen}>
<PopoverTrigger asChild>
<Button
variant='ghost'
@@ -1042,17 +1043,21 @@ export function Chat() {
{/* Buttons positioned absolutely on the right */}
<div className='-translate-y-1/2 absolute top-1/2 right-[2px] flex items-center gap-[10px]'>
<Badge
onClick={() => document.getElementById('floating-chat-file-input')?.click()}
title='Attach file'
className={cn(
'!bg-transparent !border-0 cursor-pointer rounded-[6px] p-[0px]',
(!activeWorkflowId || isExecuting || chatFiles.length >= 15) &&
'cursor-not-allowed opacity-50'
)}
>
<Paperclip className='!h-3.5 !w-3.5' />
</Badge>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
onClick={() => document.getElementById('floating-chat-file-input')?.click()}
className={cn(
'!bg-transparent !border-0 cursor-pointer rounded-[6px] p-[0px]',
(!activeWorkflowId || isExecuting || chatFiles.length >= 15) &&
'cursor-not-allowed opacity-50'
)}
>
<Paperclip className='!h-3.5 !w-3.5' />
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>Attach file</Tooltip.Content>
</Tooltip.Root>
{isStreaming ? (
<Button

View File

@@ -1,8 +0,0 @@
export { BlockContextMenu } from './block-context-menu'
export { PaneContextMenu } from './pane-context-menu'
export type {
BlockContextMenuProps,
ContextMenuBlockInfo,
ContextMenuPosition,
PaneContextMenuProps,
} from './types'

View File

@@ -1,99 +0,0 @@
import type { RefObject } from 'react'
/**
* Position for context menu placement
*/
export interface ContextMenuPosition {
x: number
y: number
}
/**
* Block information passed to context menu for action handling
*/
export interface ContextMenuBlockInfo {
/** Block ID */
id: string
/** Block type (e.g., 'agent', 'function', 'loop') */
type: string
/** Whether block is enabled */
enabled: boolean
/** Whether block uses horizontal handles */
horizontalHandles: boolean
/** Parent subflow ID if nested in loop/parallel */
parentId?: string
/** Parent type ('loop' | 'parallel') if nested */
parentType?: string
}
/**
* Props for BlockContextMenu component
*/
export interface BlockContextMenuProps {
/** Whether the context menu is open */
isOpen: boolean
/** Position of the context menu */
position: ContextMenuPosition
/** Ref for the menu element (for click-outside detection) */
menuRef: RefObject<HTMLDivElement | null>
/** Callback when menu should close */
onClose: () => void
/** Selected block(s) info */
selectedBlocks: ContextMenuBlockInfo[]
/** Callbacks for menu actions */
onCopy: () => void
onPaste: () => void
onDuplicate: () => void
onDelete: () => void
onToggleEnabled: () => void
onToggleHandles: () => void
onRemoveFromSubflow: () => void
onOpenEditor: () => void
onRename: () => void
/** Whether clipboard has content for pasting */
hasClipboard?: boolean
/** Whether remove from subflow option should be shown */
showRemoveFromSubflow?: boolean
/** Whether edit actions are disabled (no permission) */
disableEdit?: boolean
}
/**
* Props for PaneContextMenu component
*/
export interface PaneContextMenuProps {
/** Whether the context menu is open */
isOpen: boolean
/** Position of the context menu */
position: ContextMenuPosition
/** Ref for the menu element */
menuRef: RefObject<HTMLDivElement | null>
/** Callback when menu should close */
onClose: () => void
/** Callbacks for menu actions */
onUndo: () => void
onRedo: () => void
onPaste: () => void
onAddBlock: () => void
onAutoLayout: () => void
onOpenLogs: () => void
onToggleVariables: () => void
onToggleChat: () => void
onInvite: () => void
/** Whether the variables panel is currently open */
isVariablesOpen?: boolean
/** Whether the chat panel is currently open */
isChatOpen?: boolean
/** Whether clipboard has content for pasting */
hasClipboard?: boolean
/** Whether edit actions are disabled (no permission) */
disableEdit?: boolean
/** Whether admin actions are disabled (no admin permission) */
disableAdmin?: boolean
/** Whether undo is available */
canUndo?: boolean
/** Whether redo is available */
canRedo?: boolean
/** Whether invitations are disabled (feature flag or permission group) */
isInvitationsDisabled?: boolean
}

View File

@@ -4,6 +4,7 @@ import clsx from 'clsx'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useNotificationStore } from '@/stores/notifications'
import { useCopilotStore, usePanelStore } from '@/stores/panel'
import { useTerminalStore } from '@/stores/terminal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
@@ -12,6 +13,8 @@ import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('DiffControls')
const NOTIFICATION_WIDTH = 240
const NOTIFICATION_GAP = 16
export const DiffControls = memo(function DiffControls() {
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
@@ -45,6 +48,12 @@ export const DiffControls = memo(function DiffControls() {
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
)
const allNotifications = useNotificationStore((state) => state.notifications)
const hasVisibleNotifications = useMemo(() => {
if (!activeWorkflowId) return false
return allNotifications.some((n) => !n.workflowId || n.workflowId === activeWorkflowId)
}, [allNotifications, activeWorkflowId])
const createCheckpoint = useCallback(async () => {
if (!activeWorkflowId || !currentChat?.id) {
logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
@@ -295,16 +304,15 @@ export const DiffControls = memo(function DiffControls() {
const isResizing = isTerminalResizing || isPanelResizing
const notificationOffset = hasVisibleNotifications ? NOTIFICATION_WIDTH + NOTIFICATION_GAP : 0
return (
<div
ref={preventZoomRef}
className={clsx(
'fixed z-30',
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
)}
className={clsx('fixed z-30', !isResizing && 'transition-[bottom] duration-100 ease-out')}
style={{
bottom: 'calc(var(--terminal-height) + 16px)',
right: 'calc(var(--panel-width) + 16px)',
right: `calc(var(--panel-width) + 16px + ${notificationOffset}px)`,
}}
>
<div

View File

@@ -1,3 +1,5 @@
export { BlockMenu } from './block-menu'
export { CanvasMenu } from './canvas-menu'
export { CommandList } from './command-list/command-list'
export { Cursors } from './cursors/cursors'
export { DiffControls } from './diff-controls/diff-controls'
@@ -8,4 +10,5 @@ export { SubflowNodeComponent } from './subflows/subflow-node'
export { Terminal } from './terminal/terminal'
export { WandPromptBar } from './wand-prompt-bar/wand-prompt-bar'
export { WorkflowBlock } from './workflow-block/workflow-block'
export { WorkflowControls } from './workflow-controls'
export { WorkflowEdge } from './workflow-edge/workflow-edge'

View File

@@ -11,7 +11,7 @@ import {
openCopilotWithMessage,
useNotificationStore,
} from '@/stores/notifications'
import { useSidebarStore } from '@/stores/sidebar/store'
import { usePanelStore } from '@/stores/panel'
import { useTerminalStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -19,9 +19,9 @@ const logger = createLogger('Notifications')
const MAX_VISIBLE_NOTIFICATIONS = 4
/**
* Notifications display component
* Positioned in the bottom-left workspace area, reactive to sidebar width and terminal height
* Shows both global notifications and workflow-specific notifications
* Notifications display component.
* Positioned in the bottom-right workspace area, reactive to panel width and terminal height.
* Shows both global notifications and workflow-specific notifications.
*/
export const Notifications = memo(function Notifications() {
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -37,7 +37,7 @@ export const Notifications = memo(function Notifications() {
.slice(0, MAX_VISIBLE_NOTIFICATIONS)
}, [allNotifications, activeWorkflowId])
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
const isSidebarResizing = useSidebarStore((state) => state.isResizing)
const isPanelResizing = usePanelStore((state) => state.isResizing)
/**
* Executes a notification action and handles side effects.
@@ -105,15 +105,19 @@ export const Notifications = memo(function Notifications() {
return null
}
const isResizing = isTerminalResizing || isSidebarResizing
const isResizing = isTerminalResizing || isPanelResizing
return (
<div
ref={preventZoomRef}
className={clsx(
'fixed bottom-[calc(var(--terminal-height)+16px)] left-[calc(var(--sidebar-width)+16px)] z-30 flex flex-col items-start',
!isResizing && 'transition-[bottom,left] duration-100 ease-out'
'fixed z-30 flex flex-col items-start',
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
)}
style={{
bottom: 'calc(var(--terminal-height) + 16px)',
right: 'calc(var(--panel-width) + 16px)',
}}
>
{[...visibleNotifications].reverse().map((notification, index, stacked) => {
const depth = stacked.length - index - 1
@@ -123,8 +127,13 @@ export const Notifications = memo(function Notifications() {
return (
<div
key={notification.id}
style={{ transform: `translateX(${xOffset}px)` }}
className={`relative h-[80px] w-[240px] overflow-hidden rounded-[4px] border bg-[var(--surface-2)] transition-transform duration-200 ${
style={
{
'--stack-offset': `${xOffset}px`,
animation: 'notification-enter 200ms ease-out forwards',
} as React.CSSProperties
}
className={`relative h-[80px] w-[240px] overflow-hidden rounded-[4px] border bg-[var(--surface-2)] ${
index > 0 ? '-mt-[80px]' : ''
}`}
>

View File

@@ -22,7 +22,7 @@ import {
import { Skeleton } from '@/components/ui'
import type { AgentAuthentication, AgentCapabilities } from '@/lib/a2a/types'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers/triggers'
import {
useA2AAgentByWorkflow,

View File

@@ -2,11 +2,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Maximize2 } from 'lucide-react'
import {
Button,
ButtonGroup,
ButtonGroupItem,
Expand,
Label,
Modal,
ModalBody,
@@ -222,7 +222,7 @@ export function GeneralDeploy({
onClick={() => setShowExpandedPreview(true)}
className='absolute right-[8px] bottom-[8px] z-10 h-[28px] w-[28px] cursor-pointer border border-[var(--border)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
<Maximize2 className='h-[14px] w-[14px]' />
<Expand className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>See preview</Tooltip.Content>

View File

@@ -14,7 +14,7 @@ import {
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isValidStartBlockType } from '@/lib/workflows/triggers/trigger-utils'
import type { InputFormatField } from '@/lib/workflows/types'
import {

View File

@@ -189,6 +189,7 @@ export function DeployModal({
useEffect(() => {
if (open && workflowId) {
setActiveTab('general')
setApiDeployError(null)
fetchChatDeploymentInfo()
}
}, [open, workflowId, fetchChatDeploymentInfo])
@@ -507,6 +508,7 @@ export function DeployModal({
const handleCloseModal = () => {
setIsSubmitting(false)
setChatSubmitting(false)
setApiDeployError(null)
onOpenChange(false)
}
@@ -663,6 +665,12 @@ export function DeployModal({
</ModalTabsList>
<ModalBody className='min-h-0 flex-1'>
{apiDeployError && (
<div className='mb-3 rounded-[4px] border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
<div className='font-semibold'>Deployment Error</div>
<div>{apiDeployError}</div>
</div>
)}
<ModalTabsContent value='general'>
<GeneralDeploy
workflowId={workflowId}

View File

@@ -33,5 +33,4 @@ export { Table } from './table/table'
export { Text } from './text/text'
export { TimeInput } from './time-input/time-input'
export { ToolInput } from './tool-input/tool-input'
export { TriggerSave } from './trigger-save/trigger-save'
export { VariablesInput } from './variables-input/variables-input'

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { Badge, Input } from '@/components/emcn'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
@@ -7,39 +7,7 @@ import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/compon
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
/**
* Represents a field in the input format configuration
*/
interface InputFormatField {
name: string
type?: string
}
/**
* Represents an input trigger block structure
*/
interface InputTriggerBlock {
type: 'input_trigger' | 'start_trigger'
subBlocks?: {
inputFormat?: { value?: InputFormatField[] }
}
}
/**
* Represents a legacy starter block structure
*/
interface StarterBlockLegacy {
type: 'starter'
subBlocks?: {
inputFormat?: { value?: InputFormatField[] }
}
config?: {
params?: {
inputFormat?: InputFormatField[]
}
}
}
import { useWorkflowInputFields } from '@/hooks/queries/workflows'
/**
* Props for the InputMappingField component
@@ -70,73 +38,6 @@ interface InputMappingProps {
disabled?: boolean
}
/**
* Type guard to check if a value is an InputTriggerBlock
* @param value - The value to check
* @returns True if the value is an InputTriggerBlock
*/
function isInputTriggerBlock(value: unknown): value is InputTriggerBlock {
const type = (value as { type?: unknown }).type
return (
!!value && typeof value === 'object' && (type === 'input_trigger' || type === 'start_trigger')
)
}
/**
* Type guard to check if a value is a StarterBlockLegacy
* @param value - The value to check
* @returns True if the value is a StarterBlockLegacy
*/
function isStarterBlock(value: unknown): value is StarterBlockLegacy {
return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'starter'
}
/**
* Type guard to check if a value is an InputFormatField
* @param value - The value to check
* @returns True if the value is an InputFormatField
*/
function isInputFormatField(value: unknown): value is InputFormatField {
if (typeof value !== 'object' || value === null) return false
if (!('name' in value)) return false
const { name, type } = value as { name: unknown; type?: unknown }
if (typeof name !== 'string' || name.trim() === '') return false
if (type !== undefined && typeof type !== 'string') return false
return true
}
/**
* Extracts input format fields from workflow blocks
* @param blocks - The workflow blocks to extract from
* @returns Array of input format fields or null if not found
*/
function extractInputFormatFields(blocks: Record<string, unknown>): InputFormatField[] | null {
const triggerEntry = Object.entries(blocks).find(([, b]) => isInputTriggerBlock(b))
if (triggerEntry && isInputTriggerBlock(triggerEntry[1])) {
const inputFormat = triggerEntry[1].subBlocks?.inputFormat?.value
if (Array.isArray(inputFormat)) {
return (inputFormat as unknown[])
.filter(isInputFormatField)
.map((f) => ({ name: f.name, type: f.type }))
}
}
const starterEntry = Object.entries(blocks).find(([, b]) => isStarterBlock(b))
if (starterEntry && isStarterBlock(starterEntry[1])) {
const starter = starterEntry[1]
const subBlockFormat = starter.subBlocks?.inputFormat?.value
const legacyParamsFormat = starter.config?.params?.inputFormat
const chosen = Array.isArray(subBlockFormat) ? subBlockFormat : legacyParamsFormat
if (Array.isArray(chosen)) {
return (chosen as unknown[])
.filter(isInputFormatField)
.map((f) => ({ name: f.name, type: f.type }))
}
}
return null
}
/**
* InputMapping component displays and manages input field mappings for workflow execution
* @param props - The component props
@@ -168,62 +69,10 @@ export function InputMapping({
const inputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
const overlayRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const [childInputFields, setChildInputFields] = useState<InputFormatField[]>([])
const [isLoading, setIsLoading] = useState(false)
const workflowId = typeof selectedWorkflowId === 'string' ? selectedWorkflowId : undefined
const { data: childInputFields = [], isLoading } = useWorkflowInputFields(workflowId)
const [collapsedFields, setCollapsedFields] = useState<Record<string, boolean>>({})
useEffect(() => {
let isMounted = true
const controller = new AbortController()
async function fetchChildSchema() {
if (!selectedWorkflowId) {
if (isMounted) {
setChildInputFields([])
setIsLoading(false)
}
return
}
try {
if (isMounted) setIsLoading(true)
const res = await fetch(`/api/workflows/${selectedWorkflowId}`, {
signal: controller.signal,
})
if (!res.ok) {
if (isMounted) {
setChildInputFields([])
setIsLoading(false)
}
return
}
const { data } = await res.json()
const blocks = (data?.state?.blocks as Record<string, unknown>) || {}
const fields = extractInputFormatFields(blocks)
if (isMounted) {
setChildInputFields(fields || [])
setIsLoading(false)
}
} catch (error) {
if (isMounted) {
setChildInputFields([])
setIsLoading(false)
}
}
}
fetchChildSchema()
return () => {
isMounted = false
controller.abort()
}
}, [selectedWorkflowId])
const valueObj: Record<string, string> = useMemo(() => {
if (isPreview && previewValue && typeof previewValue === 'object') {
return previewValue as Record<string, string>

View File

@@ -1,7 +1,6 @@
import type React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2, WrenchIcon, XIcon } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
@@ -61,7 +60,7 @@ import {
useCustomTools,
} from '@/hooks/queries/custom-tools'
import { useForceRefreshMcpTools, useMcpServers, useStoredMcpTools } from '@/hooks/queries/mcp'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkflowInputFields, useWorkflows } from '@/hooks/queries/workflows'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
import { useSettingsModalStore } from '@/stores/modals/settings/store'
@@ -645,56 +644,7 @@ function WorkflowInputMapperSyncWrapper({
disabled: boolean
workflowId: string
}) {
const { data: workflowData, isLoading } = useQuery({
queryKey: ['workflow-input-fields', workflowId],
queryFn: async () => {
const response = await fetch(`/api/workflows/${workflowId}`)
if (!response.ok) throw new Error('Failed to fetch workflow')
const { data } = await response.json()
return data
},
enabled: Boolean(workflowId),
staleTime: 60 * 1000,
})
const inputFields = useMemo(() => {
if (!workflowData?.state?.blocks) return []
const blocks = workflowData.state.blocks as Record<string, any>
const triggerEntry = Object.entries(blocks).find(
([, block]) =>
block.type === 'start_trigger' || block.type === 'input_trigger' || block.type === 'starter'
)
if (!triggerEntry) return []
const triggerBlock = triggerEntry[1]
const inputFormat = triggerBlock.subBlocks?.inputFormat?.value
if (Array.isArray(inputFormat)) {
return inputFormat
.filter((field: any) => field.name && typeof field.name === 'string')
.map((field: any) => ({
name: field.name,
type: field.type || 'string',
}))
}
const legacyFormat = triggerBlock.config?.params?.inputFormat
if (Array.isArray(legacyFormat)) {
return legacyFormat
.filter((field: any) => field.name && typeof field.name === 'string')
.map((field: any) => ({
name: field.name,
type: field.type || 'string',
}))
}
return []
}, [workflowData])
const { data: inputFields = [], isLoading } = useWorkflowInputFields(workflowId)
const parsedValue = useMemo(() => {
try {
@@ -943,9 +893,8 @@ export function ToolInput({
const params = useParams()
const workspaceId = params.workspaceId as string
const workflowId = params.workflowId as string
const queryClient = useQueryClient()
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
const [_, setOpen] = useState(false)
const [open, setOpen] = useState(false)
const [customToolModalOpen, setCustomToolModalOpen] = useState(false)
const [editingToolIndex, setEditingToolIndex] = useState<number | null>(null)
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
@@ -2429,14 +2378,7 @@ export function ToolInput({
})()}
{(tool.type === 'workflow' || tool.type === 'workflow_input') &&
tool.params?.workflowId && (
<WorkflowToolDeployBadge
workflowId={tool.params.workflowId}
onDeploySuccess={() => {
queryClient.invalidateQueries({
queryKey: ['workflow-input-fields', tool.params?.workflowId],
})
}}
/>
<WorkflowToolDeployBadge workflowId={tool.params.workflowId} />
)}
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>

View File

@@ -1,348 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn/components'
import { Trash } from '@/components/emcn/icons/trash'
import { cn } from '@/lib/core/utils/cn'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useTriggerConfigAggregation } from '@/hooks/use-trigger-config-aggregation'
import { useWebhookManagement } from '@/hooks/use-webhook-management'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getTrigger, isTriggerValid } from '@/triggers'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
const logger = createLogger('TriggerSave')
interface TriggerSaveProps {
blockId: string
subBlockId: string
triggerId?: string
isPreview?: boolean
disabled?: boolean
}
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
export function TriggerSave({
blockId,
subBlockId,
triggerId,
isPreview = false,
disabled = false,
}: TriggerSaveProps) {
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [deleteStatus, setDeleteStatus] = useState<'idle' | 'deleting'>('idle')
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const effectiveTriggerId = useMemo(() => {
if (triggerId && isTriggerValid(triggerId)) {
return triggerId
}
const selectedTriggerId = useSubBlockStore.getState().getValue(blockId, 'selectedTriggerId')
if (typeof selectedTriggerId === 'string' && isTriggerValid(selectedTriggerId)) {
return selectedTriggerId
}
return triggerId
}, [blockId, triggerId])
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { webhookId, saveConfig, deleteConfig, isLoading } = useWebhookManagement({
blockId,
triggerId: effectiveTriggerId,
isPreview,
useWebhookUrl: true, // to store the webhook url in the store
})
const triggerConfig = useSubBlockStore((state) => state.getValue(blockId, 'triggerConfig'))
const triggerCredentials = useSubBlockStore((state) =>
state.getValue(blockId, 'triggerCredentials')
)
const triggerDef =
effectiveTriggerId && isTriggerValid(effectiveTriggerId) ? getTrigger(effectiveTriggerId) : null
const validateRequiredFields = useCallback(
(
configToCheck: Record<string, any> | null | undefined
): { valid: boolean; missingFields: string[] } => {
if (!triggerDef) {
return { valid: true, missingFields: [] }
}
const missingFields: string[] = []
triggerDef.subBlocks
.filter(
(sb) => sb.required && sb.mode === 'trigger' && !SYSTEM_SUBBLOCK_IDS.includes(sb.id)
)
.forEach((subBlock) => {
if (subBlock.id === 'triggerCredentials') {
if (!triggerCredentials) {
missingFields.push(subBlock.title || 'Credentials')
}
} else {
const value = configToCheck?.[subBlock.id]
if (value === undefined || value === null || value === '') {
missingFields.push(subBlock.title || subBlock.id)
}
}
})
return {
valid: missingFields.length === 0,
missingFields,
}
},
[triggerDef, triggerCredentials]
)
const requiredSubBlockIds = useMemo(() => {
if (!triggerDef) return []
return triggerDef.subBlocks
.filter((sb) => sb.required && sb.mode === 'trigger' && !SYSTEM_SUBBLOCK_IDS.includes(sb.id))
.map((sb) => sb.id)
}, [triggerDef])
const subscribedSubBlockValues = useSubBlockStore(
useCallback(
(state) => {
if (!triggerDef) return {}
const values: Record<string, any> = {}
requiredSubBlockIds.forEach((subBlockId) => {
const value = state.getValue(blockId, subBlockId)
if (value !== null && value !== undefined && value !== '') {
values[subBlockId] = value
}
})
return values
},
[blockId, triggerDef, requiredSubBlockIds]
)
)
const previousValuesRef = useRef<Record<string, any>>({})
const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (saveStatus !== 'error' || !triggerDef) {
previousValuesRef.current = subscribedSubBlockValues
return
}
const hasChanges = Object.keys(subscribedSubBlockValues).some(
(key) =>
previousValuesRef.current[key] !== (subscribedSubBlockValues as Record<string, any>)[key]
)
if (!hasChanges) {
return
}
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current)
}
validationTimeoutRef.current = setTimeout(() => {
const aggregatedConfig = useTriggerConfigAggregation(blockId, effectiveTriggerId)
if (aggregatedConfig) {
useSubBlockStore.getState().setValue(blockId, 'triggerConfig', aggregatedConfig)
}
const validation = validateRequiredFields(aggregatedConfig)
if (validation.valid) {
setErrorMessage(null)
setSaveStatus('idle')
logger.debug('Error cleared after validation passed', {
blockId,
triggerId: effectiveTriggerId,
})
} else {
setErrorMessage(`Missing required fields: ${validation.missingFields.join(', ')}`)
logger.debug('Error message updated', {
blockId,
triggerId: effectiveTriggerId,
missingFields: validation.missingFields,
})
}
previousValuesRef.current = subscribedSubBlockValues
}, 300)
return () => {
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current)
}
}
}, [
blockId,
effectiveTriggerId,
triggerDef,
subscribedSubBlockValues,
saveStatus,
validateRequiredFields,
])
const handleSave = async () => {
if (isPreview || disabled) return
setSaveStatus('saving')
setErrorMessage(null)
try {
const aggregatedConfig = useTriggerConfigAggregation(blockId, effectiveTriggerId)
if (aggregatedConfig) {
useSubBlockStore.getState().setValue(blockId, 'triggerConfig', aggregatedConfig)
logger.debug('Stored aggregated trigger config', {
blockId,
triggerId: effectiveTriggerId,
aggregatedConfig,
})
}
const validation = validateRequiredFields(aggregatedConfig)
if (!validation.valid) {
setErrorMessage(`Missing required fields: ${validation.missingFields.join(', ')}`)
setSaveStatus('error')
return
}
const success = await saveConfig()
if (!success) {
throw new Error('Save config returned false')
}
setSaveStatus('saved')
setErrorMessage(null)
const savedWebhookId = useSubBlockStore.getState().getValue(blockId, 'webhookId')
const savedTriggerPath = useSubBlockStore.getState().getValue(blockId, 'triggerPath')
const savedTriggerId = useSubBlockStore.getState().getValue(blockId, 'triggerId')
const savedTriggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
collaborativeSetSubblockValue(blockId, 'webhookId', savedWebhookId)
collaborativeSetSubblockValue(blockId, 'triggerPath', savedTriggerPath)
collaborativeSetSubblockValue(blockId, 'triggerId', savedTriggerId)
collaborativeSetSubblockValue(blockId, 'triggerConfig', savedTriggerConfig)
setTimeout(() => {
setSaveStatus('idle')
}, 2000)
logger.info('Trigger configuration saved successfully', {
blockId,
triggerId: effectiveTriggerId,
hasWebhookId: !!webhookId,
})
} catch (error: any) {
setSaveStatus('error')
setErrorMessage(error.message || 'An error occurred while saving.')
logger.error('Error saving trigger configuration', { error })
}
}
const handleDeleteClick = () => {
if (isPreview || disabled || !webhookId) return
setShowDeleteDialog(true)
}
const handleDeleteConfirm = async () => {
setShowDeleteDialog(false)
setDeleteStatus('deleting')
setErrorMessage(null)
try {
const success = await deleteConfig()
if (success) {
setDeleteStatus('idle')
setSaveStatus('idle')
setErrorMessage(null)
collaborativeSetSubblockValue(blockId, 'triggerPath', '')
collaborativeSetSubblockValue(blockId, 'webhookId', null)
collaborativeSetSubblockValue(blockId, 'triggerConfig', null)
logger.info('Trigger configuration deleted successfully', {
blockId,
triggerId: effectiveTriggerId,
})
} else {
setDeleteStatus('idle')
setErrorMessage('Failed to delete trigger configuration.')
logger.error('Failed to delete trigger configuration')
}
} catch (error: any) {
setDeleteStatus('idle')
setErrorMessage(error.message || 'An error occurred while deleting.')
logger.error('Error deleting trigger configuration', { error })
}
}
if (isPreview) {
return null
}
const isProcessing = saveStatus === 'saving' || deleteStatus === 'deleting' || isLoading
return (
<div id={`${blockId}-${subBlockId}`}>
<div className='flex gap-2'>
<Button
variant='default'
onClick={handleSave}
disabled={disabled || isProcessing}
className={cn(
'flex-1',
saveStatus === 'saved' && '!bg-green-600 !text-white hover:!bg-green-700',
saveStatus === 'error' && '!bg-red-600 !text-white hover:!bg-red-700'
)}
>
{saveStatus === 'saving' && 'Saving...'}
{saveStatus === 'saved' && 'Saved'}
{saveStatus === 'error' && 'Error'}
{saveStatus === 'idle' && (webhookId ? 'Update Configuration' : 'Save Configuration')}
</Button>
{webhookId && (
<Button variant='default' onClick={handleDeleteClick} disabled={disabled || isProcessing}>
<Trash className='h-[14px] w-[14px]' />
</Button>
)}
</div>
{errorMessage && <p className='mt-2 text-[12px] text-[var(--text-error)]'>{errorMessage}</p>}
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent size='sm'>
<ModalHeader>Delete Trigger</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete this trigger configuration? This will remove the
webhook and stop all incoming triggers.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button variant='active' onClick={() => setShowDeleteDialog(false)}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDeleteConfirm}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
)
}

View File

@@ -39,7 +39,6 @@ import {
Text,
TimeInput,
ToolInput,
TriggerSave,
VariablesInput,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
@@ -867,17 +866,6 @@ function SubBlockComponent({
}
/>
)
case 'trigger-save':
return (
<TriggerSave
blockId={blockId}
subBlockId={config.id}
triggerId={config.triggerId}
isPreview={isPreview}
disabled={disabled}
/>
)
case 'messages-input':
return (
<MessagesInput

View File

@@ -2,4 +2,3 @@ export { Copilot } from './copilot/copilot'
export { Deploy } from './deploy/deploy'
export { Editor } from './editor/editor'
export { Toolbar } from './toolbar/toolbar'
export { WorkflowControls } from './workflow-controls/workflow-controls'

View File

@@ -327,12 +327,14 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
/**
* Handle search input blur.
*
* We intentionally keep search mode active after blur so that ArrowUp/Down
* navigation continues to work after the first move from the search input
* into the triggers/blocks list (e.g. when initiated via Mod+F).
* If the search query is empty, deactivate search mode to show the search icon again.
* If there's a query, keep search mode active so ArrowUp/Down navigation continues
* to work after focus moves into the triggers/blocks list (e.g. when initiated via Mod+F).
*/
const handleSearchBlur = () => {
// No-op by design
if (!searchQuery.trim()) {
setIsSearchActive(false)
}
}
/**

View File

@@ -1,51 +0,0 @@
'use client'
import { Button, Redo, Undo } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
/**
* Workflow controls component that provides undo/redo functionality.
* Styled to align with the panel tab buttons.
*/
export function WorkflowControls() {
const { undo, redo } = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const { data: session } = useSession()
const userId = session?.user?.id || 'unknown'
const stacks = useUndoRedoStore((s) => s.stacks)
const undoRedoSizes = (() => {
const key = activeWorkflowId && userId ? `${activeWorkflowId}:${userId}` : ''
const stack = (key && stacks[key]) || { undo: [], redo: [] }
return { undoSize: stack.undo.length, redoSize: stack.redo.length }
})()
const canUndo = undoRedoSizes.undoSize > 0
const canRedo = undoRedoSizes.redoSize > 0
return (
<div className='flex gap-[2px]'>
<Button
className='h-[28px] rounded-[6px] rounded-r-none border border-transparent px-[6px] py-[5px] hover:border-[var(--border-1)] hover:bg-[var(--surface-5)]'
onClick={undo}
variant={canUndo ? 'active' : 'ghost'}
disabled={!canUndo}
title='Undo (Cmd+Z)'
>
<Undo className='h-[12px] w-[12px]' />
</Button>
<Button
className='h-[28px] rounded-[6px] rounded-l-none border border-transparent px-[6px] py-[5px] hover:border-[var(--border-1)] hover:bg-[var(--surface-5)]'
onClick={redo}
variant={canRedo ? 'active' : 'ghost'}
disabled={!canRedo}
title='Redo (Cmd+Shift+Z)'
>
<Redo className='h-[12px] w-[12px]' />
</Button>
</div>
)
}

View File

@@ -495,9 +495,6 @@ export function Panel() {
Editor
</Button>
</div>
{/* Workflow Controls (Undo/Redo) */}
{/* <WorkflowControls /> */}
</div>
{/* Tab Content - Keep all tabs mounted but hidden to preserve state */}

View File

@@ -148,7 +148,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
ref={blockRef}
onClick={() => setCurrentBlockId(id)}
className={cn(
'relative cursor-pointer select-none rounded-[8px] border border-[var(--border-1)]',
'workflow-drag-handle relative cursor-grab select-none rounded-[8px] border border-[var(--border-1)] [&:active]:cursor-grabbing',
'transition-block-bg transition-ring',
'z-[20]'
)}
@@ -166,11 +166,8 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
{/* Header Section */}
<div
className={cn(
'workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
'flex items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px]'
)}
onMouseDown={(e) => {
e.stopPropagation()
}}
>
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<div

View File

@@ -11,6 +11,16 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 }
const ACTION_BUTTON_STYLES = [
'h-[23px] w-[23px] rounded-[8px] p-0',
'border border-[var(--border)] bg-[var(--surface-5)]',
'text-[var(--text-secondary)]',
'hover:border-transparent hover:bg-[var(--brand-secondary)] hover:!text-[var(--text-inverse)]',
'dark:border-transparent dark:bg-[var(--surface-7)] dark:hover:bg-[var(--brand-secondary)]',
].join(' ')
const ICON_SIZE = 'h-[11px] w-[11px]'
/**
* Props for the ActionBar component
*/
@@ -110,7 +120,9 @@ export const ActionBar = memo(
'-top-[46px] absolute right-0',
'flex flex-row items-center',
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
'gap-[5px] rounded-[10px] bg-[var(--surface-4)] p-[5px]'
'gap-[5px] rounded-[10px] p-[5px]',
'border border-[var(--border)] bg-[var(--surface-2)]',
'dark:border-transparent dark:bg-[var(--surface-4)]'
)}
>
{!isNoteBlock && (
@@ -124,14 +136,10 @@ export const ActionBar = memo(
collaborativeBatchToggleBlockEnabled([blockId])
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
className={ACTION_BUTTON_STYLES}
disabled={disabled}
>
{isEnabled ? (
<Circle className='h-[11px] w-[11px]' />
) : (
<CircleOff className='h-[11px] w-[11px]' />
)}
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
@@ -151,10 +159,10 @@ export const ActionBar = memo(
handleDuplicateBlock()
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
className={ACTION_BUTTON_STYLES}
disabled={disabled}
>
<Copy className='h-[11px] w-[11px]' />
<Copy className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{getTooltipMessage('Duplicate Block')}</Tooltip.Content>
@@ -172,13 +180,13 @@ export const ActionBar = memo(
collaborativeBatchToggleBlockHandles([blockId])
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
className={ACTION_BUTTON_STYLES}
disabled={disabled}
>
{horizontalHandles ? (
<ArrowLeftRight className='h-[11px] w-[11px]' />
<ArrowLeftRight className={ICON_SIZE} />
) : (
<ArrowUpDown className='h-[11px] w-[11px]' />
<ArrowUpDown className={ICON_SIZE} />
)}
</Button>
</Tooltip.Trigger>
@@ -201,10 +209,10 @@ export const ActionBar = memo(
)
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
className={ACTION_BUTTON_STYLES}
disabled={disabled || !userPermissions.canEdit}
>
<LogOut className='h-[11px] w-[11px]' />
<LogOut className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
@@ -221,10 +229,10 @@ export const ActionBar = memo(
collaborativeBatchRemoveBlocks([blockId])
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
className={ACTION_BUTTON_STYLES}
disabled={disabled}
>
<Trash2 className='h-[11px] w-[11px]' />
<Trash2 className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{getTooltipMessage('Delete Block')}</Tooltip.Content>

View File

@@ -1,6 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { Badge, Tooltip } from '@/components/emcn'
@@ -529,7 +528,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const params = useParams()
const currentWorkflowId = params.workflowId as string
const workspaceId = params.workspaceId as string
const queryClient = useQueryClient()
const {
currentWorkflow,
@@ -602,10 +600,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
responseData.apiKey || ''
)
refetchDeployment()
// Invalidate the workflow schema cache so new config is loaded immediately
queryClient.invalidateQueries({
queryKey: ['workflow-input-fields', workflowId],
})
} else {
logger.error('Failed to deploy workflow')
}
@@ -615,7 +609,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
setIsDeploying(false)
}
},
[isDeploying, setDeploymentStatus, refetchDeployment, queryClient]
[isDeploying, setDeploymentStatus, refetchDeployment]
)
const currentStoreBlock = currentWorkflow.getBlockById(id)
@@ -927,7 +921,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
ref={contentRef}
onClick={handleClick}
className={cn(
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)]'
'workflow-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] [&:active]:cursor-grabbing'
)}
>
{isPending && (
@@ -963,12 +957,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
<div
className={cn(
'workflow-drag-handle flex cursor-grab items-center justify-between p-[8px] [&:active]:cursor-grabbing',
'flex items-center justify-between p-[8px]',
hasContentBelowHeader && 'border-[var(--border-1)] border-b'
)}
onMouseDown={(e) => {
e.stopPropagation()
}}
>
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-[10px]'>
<div

View File

@@ -0,0 +1 @@
export { WorkflowControls } from './workflow-controls'

View File

@@ -0,0 +1,225 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { Scan } from 'lucide-react'
import { useReactFlow } from 'reactflow'
import {
Button,
ChevronDown,
Cursor,
Hand,
Popover,
PopoverAnchor,
PopoverContent,
PopoverItem,
PopoverTrigger,
Redo,
Tooltip,
Undo,
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useCanvasModeStore } from '@/stores/canvas-mode'
import { useGeneralStore } from '@/stores/settings/general'
import { useTerminalStore } from '@/stores/terminal'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkflowControls')
export function WorkflowControls() {
const reactFlowInstance = useReactFlow()
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
const { mode, setMode } = useCanvasModeStore()
const { undo, redo } = useCollaborativeWorkflow()
const showWorkflowControls = useGeneralStore((s) => s.showActionBar)
const updateSetting = useUpdateGeneralSetting()
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
const { activeWorkflowId } = useWorkflowRegistry()
const { data: session } = useSession()
const userId = session?.user?.id || 'unknown'
const stacks = useUndoRedoStore((s) => s.stacks)
const key = activeWorkflowId && userId ? `${activeWorkflowId}:${userId}` : ''
const stack = (key && stacks[key]) || { undo: [], redo: [] }
const canUndo = stack.undo.length > 0
const canRedo = stack.redo.length > 0
const handleFitToView = useCallback(() => {
fitViewToBounds({ padding: 0.1, duration: 300 })
}, [fitViewToBounds])
useRegisterGlobalCommands([
createCommand({
id: 'fit-to-view',
handler: handleFitToView,
}),
])
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
const [isCanvasModeOpen, setIsCanvasModeOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
setContextMenu({ x: e.clientX, y: e.clientY })
}
const handleHide = async () => {
try {
await updateSetting.mutateAsync({ key: 'showActionBar', value: false })
} catch (error) {
logger.error('Failed to hide workflow controls', error)
} finally {
setContextMenu(null)
}
}
if (!showWorkflowControls) {
return null
}
return (
<>
<div
className={clsx(
'fixed z-10 flex h-[36px] items-center gap-[2px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] p-[4px]',
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
)}
style={{
bottom: 'calc(var(--terminal-height) + 16px)',
left: 'calc(var(--sidebar-width) + 16px)',
}}
onContextMenu={handleContextMenu}
>
{/* Canvas Mode Selector */}
<Popover
open={isCanvasModeOpen}
onOpenChange={setIsCanvasModeOpen}
variant='secondary'
size='sm'
>
<Tooltip.Root>
<PopoverTrigger asChild>
<div className='flex cursor-pointer items-center gap-[4px]'>
<Tooltip.Trigger asChild>
<Button className='h-[28px] w-[28px] rounded-[6px] p-0' variant='active'>
{mode === 'hand' ? (
<Hand className='h-[14px] w-[14px]' />
) : (
<Cursor className='h-[14px] w-[14px]' />
)}
</Button>
</Tooltip.Trigger>
<Button className='-m-[4px] !p-[6px] group' variant='ghost'>
<ChevronDown
className={`h-[8px] w-[10px] text-[var(--text-muted)] transition-transform duration-100 group-hover:text-[var(--text-secondary)] ${isCanvasModeOpen ? 'rotate-180' : ''}`}
/>
</Button>
</div>
</PopoverTrigger>
<Tooltip.Content side='top'>{mode === 'hand' ? 'Mover' : 'Pointer'}</Tooltip.Content>
</Tooltip.Root>
<PopoverContent align='center' side='top' sideOffset={8} maxWidth={100} minWidth={100}>
<PopoverItem
onClick={() => {
setMode('cursor')
setIsCanvasModeOpen(false)
}}
>
<Cursor className='h-3 w-3' />
<span>Pointer</span>
</PopoverItem>
<PopoverItem
onClick={() => {
setMode('hand')
setIsCanvasModeOpen(false)
}}
>
<Hand className='h-3 w-3' />
<span>Mover</span>
</PopoverItem>
</PopoverContent>
</Popover>
<div className='mx-[4px] h-[20px] w-[1px] bg-[var(--border)]' />
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[28px] w-[28px] rounded-[6px] p-0 hover:bg-[var(--surface-5)]'
onClick={undo}
disabled={!canUndo}
>
<Undo className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<Tooltip.Shortcut keys='⌘Z'>Undo</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[28px] w-[28px] rounded-[6px] p-0 hover:bg-[var(--surface-5)]'
onClick={redo}
disabled={!canRedo}
>
<Redo className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<Tooltip.Shortcut keys='⌘⇧Z'>Redo</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
<div className='mx-[4px] h-[20px] w-[1px] bg-[var(--border)]' />
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[28px] w-[28px] rounded-[6px] p-0 hover:bg-[var(--surface-5)]'
onClick={handleFitToView}
>
<Scan className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<Tooltip.Shortcut keys='⌘⇧F'>Fit to View</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Popover
open={contextMenu !== null}
onOpenChange={(open) => !open && setContextMenu(null)}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenu?.x ?? 0}px`,
top: `${contextMenu?.y ?? 0}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleHide}>Hide canvas controls</PopoverItem>
</PopoverContent>
</Popover>
</>
)
}

View File

@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { useReactFlow } from 'reactflow'
import type { AutoLayoutOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils'
import { applyAutoLayoutAndUpdateStore as applyAutoLayoutStandalone } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
export type { AutoLayoutOptions }
@@ -16,7 +17,8 @@ const logger = createLogger('useAutoLayout')
* Note: This hook requires a ReactFlowProvider ancestor.
*/
export function useAutoLayout(workflowId: string | null) {
const { fitView } = useReactFlow()
const reactFlowInstance = useReactFlow()
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
const applyAutoLayoutAndUpdateStore = useCallback(
async (options: AutoLayoutOptions = {}) => {
@@ -38,7 +40,7 @@ export function useAutoLayout(workflowId: string | null) {
if (result.success) {
logger.info('Auto layout completed successfully')
requestAnimationFrame(() => {
fitView({ padding: 0.8, duration: 600 })
fitViewToBounds({ padding: 0.15, duration: 600 })
})
} else {
logger.error('Auto layout failed:', result.error)
@@ -52,7 +54,7 @@ export function useAutoLayout(workflowId: string | null) {
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}, [applyAutoLayoutAndUpdateStore, fitView])
}, [applyAutoLayoutAndUpdateStore, fitViewToBounds])
return {
applyAutoLayoutAndUpdateStore,

View File

@@ -1,36 +1,28 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { Node } from 'reactflow'
import type { BlockState } from '@/stores/workflows/workflow/types'
import type { ContextMenuBlockInfo, ContextMenuPosition } from '../components/context-menu/types'
import type { BlockInfo } from '../components/block-menu'
type MenuType = 'block' | 'pane' | null
interface UseCanvasContextMenuProps {
/** Current blocks from workflow store */
blocks: Record<string, BlockState>
/** Function to get nodes from ReactFlow */
getNodes: () => Node[]
}
/**
* Hook for managing workflow canvas context menus.
*
* Handles:
* - Right-click event handling for blocks and pane
* - Menu open/close state for both menu types
* - Click-outside detection to close menus
* - Selected block info extraction for multi-selection support
* Handles right-click events, menu state, click-outside detection, and block info extraction.
*/
export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuProps) {
const [activeMenu, setActiveMenu] = useState<MenuType>(null)
const [position, setPosition] = useState<ContextMenuPosition>({ x: 0, y: 0 })
const [selectedBlocks, setSelectedBlocks] = useState<ContextMenuBlockInfo[]>([])
const [position, setPosition] = useState({ x: 0, y: 0 })
const [selectedBlocks, setSelectedBlocks] = useState<BlockInfo[]>([])
const menuRef = useRef<HTMLDivElement>(null)
/** Converts nodes to block info for context menu */
const nodesToBlockInfos = useCallback(
(nodes: Node[]): ContextMenuBlockInfo[] =>
(nodes: Node[]): BlockInfo[] =>
nodes.map((n) => {
const block = blocks[n.id]
const parentId = block?.data?.parentId
@@ -47,9 +39,6 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
[blocks]
)
/**
* Handle right-click on a node (block)
*/
const handleNodeContextMenu = useCallback(
(event: React.MouseEvent, node: Node) => {
event.preventDefault()
@@ -65,9 +54,6 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
[getNodes, nodesToBlockInfos]
)
/**
* Handle right-click on the pane (empty canvas area)
*/
const handlePaneContextMenu = useCallback((event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -77,9 +63,6 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
setActiveMenu('pane')
}, [])
/**
* Handle right-click on a selection (multiple selected nodes)
*/
const handleSelectionContextMenu = useCallback(
(event: React.MouseEvent) => {
event.preventDefault()
@@ -94,16 +77,10 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
[getNodes, nodesToBlockInfos]
)
/**
* Close the active context menu
*/
const closeMenu = useCallback(() => {
setActiveMenu(null)
}, [])
/**
* Handle clicks outside the menu to close it
*/
useEffect(() => {
if (!activeMenu) return
@@ -123,9 +100,6 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
}
}, [activeMenu, closeMenu])
/**
* Close menu on scroll or zoom to prevent menu from being positioned incorrectly
*/
useEffect(() => {
if (!activeMenu) return
@@ -139,23 +113,14 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
}, [activeMenu, closeMenu])
return {
/** Whether the block context menu is open */
isBlockMenuOpen: activeMenu === 'block',
/** Whether the pane context menu is open */
isPaneMenuOpen: activeMenu === 'pane',
/** Position for the context menu */
position,
/** Ref for the menu element */
menuRef,
/** Selected blocks info for multi-selection actions */
selectedBlocks,
/** Handler for ReactFlow onNodeContextMenu */
handleNodeContextMenu,
/** Handler for ReactFlow onPaneContextMenu */
handlePaneContextMenu,
/** Handler for ReactFlow onSelectionContextMenu */
handleSelectionContextMenu,
/** Close the active context menu */
closeMenu,
}
}

View File

@@ -31,16 +31,15 @@ import {
SubflowNodeComponent,
Terminal,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import {
BlockContextMenu,
PaneContextMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu'
import { BlockMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu'
import { CanvasMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu'
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
import type { SubflowNodeData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { TrainingModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import {
clearDragHighlights,
@@ -63,9 +62,11 @@ import { useSocket } from '@/app/workspace/providers/socket-provider'
import { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock } from '@/executor/constants'
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
import { useCanvasModeStore } from '@/stores/canvas-mode'
import { useChatStore } from '@/stores/chat/store'
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
import { useExecutionStore } from '@/stores/execution'
@@ -210,9 +211,9 @@ const WorkflowContent = React.memo(() => {
const [isCanvasReady, setIsCanvasReady] = useState(false)
const [potentialParentId, setPotentialParentId] = useState<string | null>(null)
const [selectedEdges, setSelectedEdges] = useState<SelectedEdgesMap>(new Map())
const [isShiftPressed, setIsShiftPressed] = useState(false)
const [isSelectionDragActive, setIsSelectionDragActive] = useState(false)
const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false)
const canvasMode = useCanvasModeStore((state) => state.mode)
const isHandMode = canvasMode === 'hand'
const [oauthModal, setOauthModal] = useState<{
provider: OAuthProvider
serviceId: string
@@ -223,7 +224,9 @@ const WorkflowContent = React.memo(() => {
const params = useParams()
const router = useRouter()
const { screenToFlowPosition, getNodes, setNodes, fitView, getIntersectingNodes } = useReactFlow()
const reactFlowInstance = useReactFlow()
const { screenToFlowPosition, getNodes, setNodes, getIntersectingNodes } = reactFlowInstance
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
const { emitCursorUpdate } = useSocket()
const workspaceId = params.workspaceId as string
@@ -1512,10 +1515,10 @@ const WorkflowContent = React.memo(() => {
foundNodes: changedNodes.length,
})
requestAnimationFrame(() => {
fitView({
fitViewToBounds({
nodes: changedNodes,
duration: 600,
padding: 0.3,
padding: 0.1,
minZoom: 0.5,
maxZoom: 1.0,
})
@@ -1523,18 +1526,18 @@ const WorkflowContent = React.memo(() => {
} else {
logger.info('Diff ready - no changed nodes found, fitting all')
requestAnimationFrame(() => {
fitView({ padding: 0.3, duration: 600 })
fitViewToBounds({ padding: 0.1, duration: 600 })
})
}
} else {
logger.info('Diff ready - no changed blocks, fitting all')
requestAnimationFrame(() => {
fitView({ padding: 0.3, duration: 600 })
fitViewToBounds({ padding: 0.1, duration: 600 })
})
}
}
prevDiffReadyRef.current = isDiffReady
}, [isDiffReady, diffAnalysis, fitView, getNodes])
}, [isDiffReady, diffAnalysis, fitViewToBounds, getNodes])
/** Displays trigger warning notifications. */
useEffect(() => {
@@ -1926,47 +1929,6 @@ const WorkflowContent = React.memo(() => {
// Local state for nodes - allows smooth drag without store updates on every frame
const [displayNodes, setDisplayNodes] = useState<Node[]>([])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') setIsShiftPressed(true)
}
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') setIsShiftPressed(false)
}
const handleFocusLoss = () => {
setIsShiftPressed(false)
setIsSelectionDragActive(false)
}
const handleVisibilityChange = () => {
if (document.hidden) {
handleFocusLoss()
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('blur', handleFocusLoss)
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('blur', handleFocusLoss)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [])
useEffect(() => {
if (isShiftPressed) {
document.body.style.userSelect = 'none'
} else {
document.body.style.userSelect = ''
}
return () => {
document.body.style.userSelect = ''
}
}, [isShiftPressed])
useEffect(() => {
// Check for pending selection (from paste/duplicate), otherwise preserve existing selection
const pendingSelection = pendingSelectionRef.current
@@ -2867,19 +2829,19 @@ const WorkflowContent = React.memo(() => {
]
)
// Lock selection mode when selection drag starts (captures Shift state at drag start)
const onSelectionStart = useCallback(() => {
if (isShiftPressed) {
setIsSelectionDragActive(true)
}
}, [isShiftPressed])
// // Lock selection mode when selection drag starts (captures Shift state at drag start)
// const onSelectionStart = useCallback(() => {
// if (isShiftPressed) {
// setIsSelectionDragActive(true)
// }
// }, [isShiftPressed])
const onSelectionEnd = useCallback(() => {
requestAnimationFrame(() => {
setIsSelectionDragActive(false)
setDisplayNodes((nodes) => resolveParentChildSelectionConflicts(nodes, blocks))
})
}, [blocks])
// const onSelectionEnd = useCallback(() => {
// requestAnimationFrame(() => {
// setIsSelectionDragActive(false)
// setDisplayNodes((nodes) => resolveParentChildSelectionConflicts(nodes, blocks))
// })
// }, [blocks])
/** Captures initial positions when selection drag starts (for marquee-selected nodes). */
const onSelectionDragStart = useCallback(
@@ -3038,7 +3000,6 @@ const WorkflowContent = React.memo(() => {
const onSelectionDragStop = useCallback(
(_event: React.MouseEvent, nodes: any[]) => {
requestAnimationFrame(() => setIsSelectionDragActive(false))
clearDragHighlights()
if (nodes.length === 0) return
@@ -3367,11 +3328,9 @@ const WorkflowContent = React.memo(() => {
onPointerMove={handleCanvasPointerMove}
onPointerLeave={handleCanvasPointerLeave}
elementsSelectable={true}
selectionOnDrag={isShiftPressed || isSelectionDragActive}
selectionOnDrag={!isHandMode}
selectionMode={SelectionMode.Partial}
panOnDrag={isShiftPressed || isSelectionDragActive ? false : [0, 1]}
onSelectionStart={onSelectionStart}
onSelectionEnd={onSelectionEnd}
panOnDrag={isHandMode ? [0, 1] : false}
multiSelectionKeyCode={['Meta', 'Control', 'Shift']}
nodesConnectable={effectivePermissions.canEdit}
nodesDraggable={effectivePermissions.canEdit}
@@ -3379,7 +3338,7 @@ const WorkflowContent = React.memo(() => {
noWheelClassName='allow-scroll'
edgesFocusable={true}
edgesUpdatable={effectivePermissions.canEdit}
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`}
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
onSelectionDragStart={effectivePermissions.canEdit ? onSelectionDragStart : undefined}
@@ -3398,12 +3357,14 @@ const WorkflowContent = React.memo(() => {
<Cursors />
<WorkflowControls />
<Suspense fallback={null}>
<LazyChat />
</Suspense>
{/* Context Menus */}
<BlockContextMenu
<BlockMenu
isOpen={isBlockMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
@@ -3425,7 +3386,7 @@ const WorkflowContent = React.memo(() => {
disableEdit={!effectivePermissions.canEdit}
/>
<PaneContextMenu
<CanvasMenu
isOpen={isPaneMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
@@ -3435,6 +3396,7 @@ const WorkflowContent = React.memo(() => {
onPaste={handleContextPaste}
onAddBlock={handleContextAddBlock}
onAutoLayout={handleAutoLayout}
onFitToView={() => fitViewToBounds({ padding: 0.1, duration: 300 })}
onOpenLogs={handleContextOpenLogs}
onToggleVariables={handleContextToggleVariables}
onToggleChat={handleContextToggleChat}

View File

@@ -1124,7 +1124,9 @@ function BlockDetailsSidebarContent({
const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden || subBlock.hideFromPreview) return false
if (subBlock.mode === 'trigger') return false
// Only filter out trigger-mode subblocks for non-trigger blocks
// Trigger-only blocks (category 'triggers') should display their trigger subblocks
if (subBlock.mode === 'trigger' && blockConfig.category !== 'triggers') return false
if (subBlock.condition) {
return evaluateCondition(subBlock.condition, subBlockValues)
}

View File

@@ -46,7 +46,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
return blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden) return false
if (subBlock.hideFromPreview) return false
if (subBlock.mode === 'trigger') return false
if (subBlock.mode === 'trigger' && blockConfig.category !== 'triggers') return false
if (subBlock.mode === 'advanced') return false
return true
})

View File

@@ -87,6 +87,12 @@ function GeneralSkeleton() {
<Skeleton className='h-8 w-[100px] rounded-[4px]' />
</div>
{/* Show canvas controls row */}
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-[17px] w-[30px] rounded-full' />
</div>
{/* Telemetry row */}
<div className='flex items-center justify-between border-t pt-[16px]'>
<Skeleton className='h-4 w-44' />
@@ -310,6 +316,12 @@ export function General({ onOpenChange }: GeneralProps) {
}
}
const handleShowActionBarChange = async (checked: boolean) => {
if (checked !== settings?.showActionBar && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'showActionBar', value: checked })
}
}
const handleTrainingControlsChange = async (checked: boolean) => {
if (checked !== settings?.showTrainingControls && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'showTrainingControls', value: checked })
@@ -519,6 +531,15 @@ export function General({ onOpenChange }: GeneralProps) {
</div>
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='show-action-bar'>Show canvas controls</Label>
<Switch
id='show-action-bar'
checked={settings?.showActionBar ?? true}
onCheckedChange={handleShowActionBarChange}
/>
</div>
<div className='flex items-center justify-between border-t pt-[16px]'>
<Label htmlFor='telemetry'>Allow anonymous telemetry</Label>
<Switch

View File

@@ -369,7 +369,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
autoConnect: data.autoConnect ?? true,
showTrainingControls: data.showTrainingControls ?? false,
superUserModeEnabled: data.superUserModeEnabled ?? true,
theme: data.theme || 'system',
theme: data.theme || 'dark',
telemetryEnabled: data.telemetryEnabled ?? true,
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
}

View File

@@ -11,9 +11,9 @@ export const PermissionsTableSkeleton = React.memo(() => (
</div>
<div className='flex flex-shrink-0 items-center'>
<div className='inline-flex gap-[2px]'>
<Skeleton className='h-[26px] w-[44px] rounded-[4px]' />
<Skeleton className='h-[26px] w-[44px] rounded-[4px]' />
<Skeleton className='h-[26px] w-[44px] rounded-[4px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
</div>
</div>
</div>

View File

@@ -194,6 +194,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
const matches = text.match(emailRegex) || []
return [...new Set(matches.map((e) => e.toLowerCase()))]
},
tooltip: 'Upload emails',
}),
[userPerms.canAdmin]
)

View File

@@ -136,7 +136,6 @@ export function WorkspaceHeader({
const [editingWorkspaceId, setEditingWorkspaceId] = useState<string | null>(null)
const [editingName, setEditingName] = useState('')
const [isListRenaming, setIsListRenaming] = useState(false)
const listRenameInputRef = useRef<HTMLInputElement | null>(null)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
@@ -146,6 +145,10 @@ export function WorkspaceHeader({
name: string
permissions?: 'admin' | 'write' | 'read' | null
} | null>(null)
const isRenamingRef = useRef(false)
const isContextMenuOpeningRef = useRef(false)
const contextMenuClosedRef = useRef(true)
const hasInputFocusedRef = useRef(false)
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
@@ -165,20 +168,6 @@ export function WorkspaceHeader({
return () => window.removeEventListener('open-invite-modal', handleOpenInvite)
}, [isInvitationsDisabled])
/**
* Focus the inline list rename input when it becomes active
*/
useEffect(() => {
if (editingWorkspaceId && listRenameInputRef.current) {
try {
listRenameInputRef.current.focus()
listRenameInputRef.current.select()
} catch {
// no-op
}
}
}, [editingWorkspaceId])
/**
* Save and exit edit mode when popover closes
*/
@@ -201,6 +190,9 @@ export function WorkspaceHeader({
e.preventDefault()
e.stopPropagation()
isContextMenuOpeningRef.current = true
contextMenuClosedRef.current = false
capturedWorkspaceRef.current = {
id: workspace.id,
name: workspace.name,
@@ -211,11 +203,22 @@ export function WorkspaceHeader({
}
/**
* Close context menu and the workspace dropdown
* Close context menu and optionally the workspace dropdown
* When renaming, we keep the workspace menu open so the input is visible
* This function is idempotent - duplicate calls are ignored
*/
const closeContextMenu = () => {
if (contextMenuClosedRef.current) {
return
}
contextMenuClosedRef.current = true
setIsContextMenuOpen(false)
setIsWorkspaceMenuOpen(false)
isContextMenuOpeningRef.current = false
if (!isRenamingRef.current) {
setIsWorkspaceMenuOpen(false)
}
isRenamingRef.current = false
}
/**
@@ -224,8 +227,11 @@ export function WorkspaceHeader({
const handleRenameAction = () => {
if (!capturedWorkspaceRef.current) return
isRenamingRef.current = true
hasInputFocusedRef.current = false
setEditingWorkspaceId(capturedWorkspaceRef.current.id)
setEditingName(capturedWorkspaceRef.current.name)
setIsWorkspaceMenuOpen(true)
}
/**
@@ -287,8 +293,10 @@ export function WorkspaceHeader({
<Popover
open={isWorkspaceMenuOpen}
onOpenChange={(open) => {
// Don't close if context menu is opening
if (!open && isContextMenuOpen) {
if (
!open &&
(isContextMenuOpen || isContextMenuOpeningRef.current || editingWorkspaceId)
) {
return
}
setIsWorkspaceMenuOpen(open)
@@ -298,10 +306,15 @@ export function WorkspaceHeader({
<button
type='button'
aria-label='Switch workspace'
className={`flex cursor-pointer items-center gap-[8px] rounded-[6px] bg-transparent px-[6px] py-[4px] transition-colors hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)] ${
className={`group flex cursor-pointer items-center gap-[8px] rounded-[6px] bg-transparent px-[6px] py-[4px] transition-colors hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)] ${
isCollapsed ? '' : '-mx-[6px] min-w-0 max-w-full'
}`}
title={activeWorkspace?.name || 'Loading...'}
onContextMenu={(e) => {
if (activeWorkspaceFull) {
handleContextMenu(e, activeWorkspaceFull)
}
}}
>
<span
className={`font-base text-[14px] text-[var(--text-primary)] ${
@@ -311,7 +324,7 @@ export function WorkspaceHeader({
{activeWorkspace?.name || 'Loading...'}
</span>
<ChevronDown
className={`h-[8px] w-[12px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100 ${
className={`h-[8px] w-[10px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100 group-hover:text-[var(--text-secondary)] ${
isWorkspaceMenuOpen ? 'rotate-180' : ''
}`}
/>
@@ -386,7 +399,13 @@ export function WorkspaceHeader({
{editingWorkspaceId === workspace.id ? (
<div className='flex h-[26px] items-center gap-[8px] rounded-[8px] bg-[var(--surface-5)] px-[6px]'>
<input
ref={listRenameInputRef}
ref={(el) => {
if (el && !hasInputFocusedRef.current) {
hasInputFocusedRef.current = true
el.focus()
el.select()
}
}}
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={async (e) => {
@@ -406,15 +425,18 @@ export function WorkspaceHeader({
}}
onBlur={async () => {
if (!editingWorkspaceId) return
setIsListRenaming(true)
try {
await onRenameWorkspace(workspace.id, editingName.trim())
setEditingWorkspaceId(null)
} finally {
setIsListRenaming(false)
const trimmedName = editingName.trim()
if (trimmedName && trimmedName !== workspace.name) {
setIsListRenaming(true)
try {
await onRenameWorkspace(workspace.id, trimmedName)
} finally {
setIsListRenaming(false)
}
}
setEditingWorkspaceId(null)
}}
className='w-full border-0 bg-transparent p-0 font-base text-[13px] text-[var(--text-primary)] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
className='w-full border-0 bg-transparent p-0 font-base text-[13px] text-[var(--text-primary)] outline-none selection:bg-[#add6ff] selection:text-[#1b1b1b] focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:selection:bg-[#264f78] dark:selection:text-white'
maxLength={100}
autoComplete='off'
autoCorrect='off'
@@ -422,7 +444,6 @@ export function WorkspaceHeader({
spellCheck='false'
disabled={isListRenaming}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
/>
@@ -460,7 +481,7 @@ export function WorkspaceHeader({
>
{activeWorkspace?.name || 'Loading...'}
</span>
<ChevronDown className='h-[8px] w-[12px] flex-shrink-0 text-[var(--text-muted)]' />
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0 text-[var(--text-muted)]' />
</button>
)}
</div>

View File

@@ -161,7 +161,10 @@ export function useWorkspaceManagement({
}
// Update local state immediately after successful API call
setActiveWorkspace((prev) => (prev ? { ...prev, name: newName.trim() } : null))
// Only update activeWorkspace if it's the one being renamed
setActiveWorkspace((prev) =>
prev && prev.id === workspaceId ? { ...prev, name: newName.trim() } : prev
)
setWorkspaces((prev) =>
prev.map((workspace) =>
workspace.id === workspaceId ? { ...workspace, name: newName.trim() } : workspace

View File

@@ -489,7 +489,7 @@ export function Sidebar() {
<>
{isCollapsed ? (
/* Floating collapsed header - minimal pill showing workspace name and expand toggle */
<div className='fixed top-[14px] left-[10px] z-10 w-fit rounded-[10px] border border-[var(--border)] bg-[var(--surface-1)] px-[10px] py-[6px]'>
<div className='fixed top-[14px] left-[10px] z-10 w-fit rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] py-[4px] pr-[10px] pl-[6px]'>
<WorkspaceHeader
activeWorkspace={activeWorkspace}
workspaceId={workspaceId}

View File

@@ -72,6 +72,9 @@ export type SubBlockType =
| 'mcp-dynamic-args' // MCP dynamic arguments based on tool schema
| 'input-format' // Input structure format
| 'response-format' // Response structure format
/**
* @deprecated Legacy trigger save subblock type.
*/
| 'trigger-save' // Trigger save button with validation
| 'file-upload' // File uploader
| 'input-mapping' // Map parent variables to child workflow input schema

View File

@@ -40,6 +40,7 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Paperclip, Plus, X } from 'lucide-react'
import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
import { cn } from '@/lib/core/utils/cn'
/**
@@ -155,6 +156,8 @@ export interface FileInputOptions {
icon?: React.ComponentType<{ className?: string; strokeWidth?: number }>
/** Extract values from file content. Each extracted value will be passed to onAdd. */
extractValues?: (text: string) => string[]
/** Tooltip text for the file input button */
tooltip?: string
}
/**
@@ -480,17 +483,24 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
)}
</div>
{fileInputEnabled && !disabled && (
<button
type='button'
onClick={(e) => {
e.stopPropagation()
fileInputRef.current?.click()
}}
className='absolute right-[8px] bottom-[9px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-secondary)]'
aria-label='Upload file'
>
<FileIcon className='h-[14px] w-[14px]' strokeWidth={2} />
</button>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
fileInputRef.current?.click()
}}
className='absolute right-[8px] bottom-[9px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-secondary)]'
aria-label={fileInputOptions?.tooltip ?? 'Upload file'}
>
<FileIcon className='h-[14px] w-[14px]' strokeWidth={2} />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{fileInputOptions?.tooltip ?? 'Upload file'}
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
)

View File

@@ -7,7 +7,12 @@ import { cn } from '@/lib/core/utils/cn'
/**
* Tooltip provider component that must wrap your app or tooltip usage area.
*/
const Provider = TooltipPrimitive.Provider
const Provider = ({
delayDuration = 400,
...props
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Provider>) => (
<TooltipPrimitive.Provider delayDuration={delayDuration} {...props} />
)
/**
* Root tooltip component that wraps trigger and content.

View File

@@ -0,0 +1,22 @@
import type { SVGProps } from 'react'
export function Cursor(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M20.5056 10.7754C21.1225 10.5355 21.431 10.4155 21.5176 10.2459C21.5926 10.099 21.5903 9.92446 21.5115 9.77954C21.4205 9.61226 21.109 9.50044 20.486 9.2768L4.59629 3.5728C4.0866 3.38983 3.83175 3.29835 3.66514 3.35605C3.52029 3.40621 3.40645 3.52004 3.35629 3.6649C3.29859 3.8315 3.39008 4.08635 3.57304 4.59605L9.277 20.4858C9.50064 21.1088 9.61246 21.4203 9.77973 21.5113C9.92465 21.5901 10.0991 21.5924 10.2461 21.5174C10.4157 21.4308 10.5356 21.1223 10.7756 20.5054L13.3724 13.8278C13.4194 13.707 13.4429 13.6466 13.4792 13.5957C13.5114 13.5506 13.5508 13.5112 13.5959 13.479C13.6468 13.4427 13.7072 13.4192 13.828 13.3722L20.5056 10.7754Z'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}

View File

@@ -0,0 +1,43 @@
import type { SVGProps } from 'react'
export function Expand(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M15 3H21V9'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M9 21H3V15'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M21 3L14 10'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M3 21L10 14'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}

View File

@@ -0,0 +1,43 @@
import type { SVGProps } from 'react'
export function Hand(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M6.5 11V6.5C6.5 5.67157 7.17157 5 8 5C8.82843 5 9.5 5.67157 9.5 6.5V11'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M9.5 10.5V5.5C9.5 4.67157 10.1716 4 11 4C11.8284 4 12.5 4.67157 12.5 5.5V10.5'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M12.5 10.5V6.5C12.5 5.67157 13.1716 5 14 5C14.8284 5 15.5 5.67157 15.5 6.5V10.5'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M15.5 10.5V8.5C15.5 7.67157 16.1716 7 17 7C17.8284 7 18.5 7.67157 18.5 8.5V15.5C18.5 18.8137 15.8137 21.5 12.5 21.5H11.5C8.18629 21.5 5.5 18.8137 5.5 15.5V13C5.5 12.1716 6.17157 11.5 7 11.5C7.82843 11.5 8.5 12.1716 8.5 13'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}

View File

@@ -4,12 +4,15 @@ export { Card } from './card'
export { ChevronDown } from './chevron-down'
export { Connections } from './connections'
export { Copy } from './copy'
export { Cursor } from './cursor'
export { DocumentAttachment } from './document-attachment'
export { Download } from './download'
export { Duplicate } from './duplicate'
export { Expand } from './expand'
export { Eye } from './eye'
export { FolderCode } from './folder-code'
export { FolderPlus } from './folder-plus'
export { Hand } from './hand'
export { HexSimple } from './hex-simple'
export { Key } from './key'
export { Layout } from './layout'

View File

@@ -17,14 +17,14 @@ export function Redo(props: SVGProps<SVGSVGElement>) {
<path
d='M9.5 4.5H4C2.61929 4.5 1.5 5.61929 1.5 7C1.5 8.38071 2.61929 9.5 4 9.5H7'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M8 2.5L10 4.5L8 6.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>

View File

@@ -17,14 +17,14 @@ export function Undo(props: SVGProps<SVGSVGElement>) {
<path
d='M2.5 4.5H8C9.38071 4.5 10.5 5.61929 10.5 7C10.5 8.38071 9.38071 9.5 8 9.5H5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M4 2.5L2 4.5L4 6.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>

View File

@@ -19,28 +19,28 @@ export function ZoomIn(props: SVGProps<SVGSVGElement>) {
cy='5'
r='3.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M5 3.5V6.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M3.5 5H6.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M7.5 7.5L10.5 10.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>

View File

@@ -19,21 +19,21 @@ export function ZoomOut(props: SVGProps<SVGSVGElement>) {
cy='5'
r='3.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M3.5 5H6.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M7.5 7.5L10.5 10.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>

View File

@@ -1,929 +0,0 @@
import { vi } from 'vitest'
import type { SerializedWorkflow } from '@/serializer/types'
/**
* Mock handler factory - creates consistent handler mocks
*/
export const createMockHandler = (
handlerName: string,
options?: {
canHandleCondition?: (block: any) => boolean
executeResult?: any | ((inputs: any) => any)
}
) => {
const defaultCanHandle = (block: any) =>
block.metadata?.id === handlerName || handlerName === 'generic'
const defaultExecuteResult = {
result: `${handlerName} executed`,
}
return vi.fn().mockImplementation(() => ({
canHandle: options?.canHandleCondition || defaultCanHandle,
execute: vi.fn().mockImplementation(async (block, inputs) => {
if (typeof options?.executeResult === 'function') {
return options.executeResult(inputs)
}
return options?.executeResult || defaultExecuteResult
}),
}))
}
/**
* Setup all handler mocks with default behaviors
*/
export const setupHandlerMocks = () => {
vi.doMock('@/executor/handlers', () => ({
TriggerBlockHandler: createMockHandler('trigger', {
canHandleCondition: (block) =>
block.metadata?.category === 'triggers' || block.config?.params?.triggerMode === true,
executeResult: (inputs: any) => inputs || {},
}),
AgentBlockHandler: createMockHandler('agent'),
RouterBlockHandler: createMockHandler('router'),
ConditionBlockHandler: createMockHandler('condition'),
EvaluatorBlockHandler: createMockHandler('evaluator'),
FunctionBlockHandler: createMockHandler('function'),
ApiBlockHandler: createMockHandler('api'),
LoopBlockHandler: createMockHandler('loop'),
ParallelBlockHandler: createMockHandler('parallel'),
WorkflowBlockHandler: createMockHandler('workflow'),
VariablesBlockHandler: createMockHandler('variables'),
WaitBlockHandler: createMockHandler('wait'),
GenericBlockHandler: createMockHandler('generic'),
ResponseBlockHandler: createMockHandler('response'),
}))
}
/**
* Setup store mocks with configurable options
*/
export const setupStoreMocks = (options?: {
isDebugging?: boolean
consoleAddFn?: ReturnType<typeof vi.fn>
consoleUpdateFn?: ReturnType<typeof vi.fn>
}) => {
const consoleAddFn = options?.consoleAddFn || vi.fn()
const consoleUpdateFn = options?.consoleUpdateFn || vi.fn()
vi.doMock('@/stores/settings/general/store', () => ({
useGeneralStore: {
getState: () => ({}),
},
}))
vi.doMock('@/stores/execution/store', () => ({
useExecutionStore: {
getState: () => ({
isDebugging: options?.isDebugging ?? false,
setIsExecuting: vi.fn(),
reset: vi.fn(),
setActiveBlocks: vi.fn(),
setPendingBlocks: vi.fn(),
setIsDebugging: vi.fn(),
}),
setState: vi.fn(),
},
}))
vi.doMock('@/stores/console/store', () => ({
useConsoleStore: {
getState: () => ({
addConsole: consoleAddFn,
}),
},
}))
vi.doMock('@/stores/terminal', () => ({
useTerminalConsoleStore: {
getState: () => ({
addConsole: consoleAddFn,
updateConsole: consoleUpdateFn,
}),
},
}))
return { consoleAddFn, consoleUpdateFn }
}
/**
* Setup core executor mocks (PathTracker, InputResolver, LoopManager, ParallelManager)
*/
export const setupExecutorCoreMocks = () => {
vi.doMock('@/executor/path', () => ({
PathTracker: vi.fn().mockImplementation(() => ({
updateExecutionPaths: vi.fn(),
isInActivePath: vi.fn().mockReturnValue(true),
})),
}))
vi.doMock('@/executor/resolver', () => ({
InputResolver: vi.fn().mockImplementation(() => ({
resolveInputs: vi.fn().mockReturnValue({}),
resolveBlockReferences: vi.fn().mockImplementation((value) => value),
resolveVariableReferences: vi.fn().mockImplementation((value) => value),
resolveEnvVariables: vi.fn().mockImplementation((value) => value),
})),
}))
vi.doMock('@/executor/loops', () => ({
LoopManager: vi.fn().mockImplementation(() => ({
processLoopIterations: vi.fn().mockResolvedValue(false),
getLoopIndex: vi.fn().mockImplementation((loopId, blockId, context) => {
return context.loopExecutions?.get(loopId)?.iteration || 0
}),
})),
}))
vi.doMock('@/executor/parallels', () => ({
ParallelManager: vi.fn().mockImplementation(() => ({
processParallelIterations: vi.fn().mockResolvedValue(false),
createVirtualBlockInstances: vi.fn().mockReturnValue([]),
setupIterationContext: vi.fn(),
storeIterationResult: vi.fn(),
initializeParallel: vi.fn(),
getIterationItem: vi.fn(),
areAllVirtualBlocksExecuted: vi.fn().mockReturnValue(false),
})),
}))
}
/**
* Workflow factory functions
*/
export const createMinimalWorkflow = (): SerializedWorkflow => ({
version: '1.0',
blocks: [
{
id: 'starter',
position: { x: 0, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'starter', name: 'Starter Block' },
},
{
id: 'block1',
position: { x: 100, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'test', name: 'Test Block' },
},
],
connections: [
{
source: 'starter',
target: 'block1',
},
],
loops: {},
})
export const createWorkflowWithCondition = (): SerializedWorkflow => ({
version: '1.0',
blocks: [
{
id: 'starter',
position: { x: 0, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'starter', name: 'Starter Block' },
},
{
id: 'condition1',
position: { x: 100, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'condition', name: 'Condition Block' },
},
{
id: 'block1',
position: { x: 200, y: -50 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'test', name: 'True Path Block' },
},
{
id: 'block2',
position: { x: 200, y: 50 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'test', name: 'False Path Block' },
},
],
connections: [
{
source: 'starter',
target: 'condition1',
},
{
source: 'condition1',
target: 'block1',
sourceHandle: 'condition-true',
},
{
source: 'condition1',
target: 'block2',
sourceHandle: 'condition-false',
},
],
loops: {},
})
export const createWorkflowWithLoop = (): SerializedWorkflow => ({
version: '1.0',
blocks: [
{
id: 'starter',
position: { x: 0, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'starter', name: 'Starter Block' },
},
{
id: 'block1',
position: { x: 100, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'test', name: 'Loop Block 1' },
},
{
id: 'block2',
position: { x: 200, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'test', name: 'Loop Block 2' },
},
],
connections: [
{
source: 'starter',
target: 'block1',
},
{
source: 'block1',
target: 'block2',
},
{
source: 'block2',
target: 'block1',
},
],
loops: {
loop1: {
id: 'loop1',
nodes: ['block1', 'block2'],
iterations: 5,
loopType: 'forEach',
forEachItems: [1, 2, 3, 4, 5],
},
},
})
export const createWorkflowWithErrorPath = (): SerializedWorkflow => ({
version: '1.0',
blocks: [
{
id: 'starter',
position: { x: 0, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'starter', name: 'Starter Block' },
},
{
id: 'block1',
position: { x: 100, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'function', name: 'Function Block' },
},
{
id: 'error-handler',
position: { x: 200, y: 50 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'test', name: 'Error Handler Block' },
},
{
id: 'success-block',
position: { x: 200, y: -50 },
config: { tool: 'test-tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
metadata: { id: 'test', name: 'Success Block' },
},
],
connections: [
{
source: 'starter',
target: 'block1',
},
{
source: 'block1',
target: 'success-block',
sourceHandle: 'source',
},
{
source: 'block1',
target: 'error-handler',
sourceHandle: 'error',
},
],
loops: {},
})
export const createWorkflowWithParallel = (distribution?: any): SerializedWorkflow => ({
version: '2.0',
blocks: [
{
id: 'starter',
position: { x: 0, y: 0 },
metadata: { id: 'starter', name: 'Start' },
config: { tool: 'starter', params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'parallel-1',
position: { x: 100, y: 0 },
metadata: { id: 'parallel', name: 'Test Parallel' },
config: { tool: 'parallel', params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'function-1',
position: { x: 200, y: 0 },
metadata: { id: 'function', name: 'Process Item' },
config: {
tool: 'function',
params: {
code: 'return { item: <parallel.currentItem>, index: <parallel.index> }',
},
},
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'endpoint',
position: { x: 300, y: 0 },
metadata: { id: 'generic', name: 'End' },
config: { tool: 'generic', params: {} },
inputs: {},
outputs: {},
enabled: true,
},
],
connections: [
{ source: 'starter', target: 'parallel-1' },
{ source: 'parallel-1', target: 'function-1', sourceHandle: 'parallel-start-source' },
{ source: 'parallel-1', target: 'endpoint', sourceHandle: 'parallel-end-source' },
],
loops: {},
parallels: {
'parallel-1': {
id: 'parallel-1',
nodes: ['function-1'],
distribution: distribution || ['apple', 'banana', 'cherry'],
},
},
})
export const createWorkflowWithResponse = (): SerializedWorkflow => ({
version: '1.0',
blocks: [
{
id: 'starter',
position: { x: 0, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {
input: 'json',
},
outputs: {
response: { type: 'json', description: 'Input response' },
},
enabled: true,
metadata: { id: 'starter', name: 'Starter Block' },
},
{
id: 'response',
position: { x: 100, y: 0 },
config: { tool: 'test-tool', params: {} },
inputs: {
data: 'json',
status: 'number',
headers: 'json',
},
outputs: {
data: { type: 'json', description: 'Response data' },
status: { type: 'number', description: 'Response status' },
headers: { type: 'json', description: 'Response headers' },
},
enabled: true,
metadata: { id: 'response', name: 'Response Block' },
},
],
connections: [{ source: 'starter', target: 'response' }],
loops: {},
})
/**
* Create a mock execution context with customizable options
*/
export interface MockContextOptions {
workflowId?: string
loopExecutions?: Map<string, any>
executedBlocks?: Set<string>
activeExecutionPath?: Set<string>
completedLoops?: Set<string>
parallelExecutions?: Map<string, any>
parallelBlockMapping?: Map<string, any>
currentVirtualBlockId?: string
workflow?: SerializedWorkflow
blockStates?: Map<string, any>
}
export const createMockContext = (options: MockContextOptions = {}) => {
const workflow = options.workflow || createMinimalWorkflow()
return {
workflowId: options.workflowId || 'test-workflow-id',
blockStates: options.blockStates || new Map(),
blockLogs: [],
metadata: { startTime: new Date().toISOString(), duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: options.loopExecutions || new Map(),
executedBlocks: options.executedBlocks || new Set<string>(),
activeExecutionPath: options.activeExecutionPath || new Set<string>(),
workflow,
completedLoops: options.completedLoops || new Set<string>(),
parallelExecutions: options.parallelExecutions || new Map(),
parallelBlockMapping: options.parallelBlockMapping,
currentVirtualBlockId: options.currentVirtualBlockId,
}
}
/**
* Mock implementations for testing loops
*/
export const createLoopManagerMock = (options?: {
processLoopIterationsImpl?: (context: any) => Promise<boolean>
getLoopIndexImpl?: (loopId: string, blockId: string, context: any) => number
}) => ({
LoopManager: vi.fn().mockImplementation(() => ({
processLoopIterations: options?.processLoopIterationsImpl || vi.fn().mockResolvedValue(false),
getLoopIndex:
options?.getLoopIndexImpl ||
vi.fn().mockImplementation((loopId, blockId, context) => {
return context.loopExecutions?.get(loopId)?.iteration || 0
}),
})),
})
/**
* Create a parallel execution state object for testing
*/
export const createParallelExecutionState = (options?: {
parallelCount?: number
distributionItems?: any[] | Record<string, any> | null
completedExecutions?: number
executionResults?: Map<string, any>
activeIterations?: Set<number>
parallelType?: 'count' | 'collection'
}) => ({
parallelCount: options?.parallelCount ?? 3,
distributionItems:
options?.distributionItems !== undefined ? options.distributionItems : ['a', 'b', 'c'],
completedExecutions: options?.completedExecutions ?? 0,
executionResults: options?.executionResults ?? new Map<string, any>(),
activeIterations: options?.activeIterations ?? new Set<number>(),
parallelType: options?.parallelType,
})
/**
* Mock implementations for testing parallels
*/
export const createParallelManagerMock = (options?: {
maxChecks?: number
processParallelIterationsImpl?: (context: any) => Promise<void>
}) => ({
ParallelManager: vi.fn().mockImplementation(() => {
const executionCounts = new Map()
const maxChecks = options?.maxChecks || 2
return {
processParallelIterations:
options?.processParallelIterationsImpl ||
vi.fn().mockImplementation(async (context) => {
for (const [parallelId, parallel] of Object.entries(context.workflow?.parallels || {})) {
if (context.completedLoops.has(parallelId)) {
continue
}
const parallelState = context.parallelExecutions?.get(parallelId)
if (!parallelState) {
continue
}
const checkCount = executionCounts.get(parallelId) || 0
executionCounts.set(parallelId, checkCount + 1)
if (checkCount >= maxChecks) {
context.completedLoops.add(parallelId)
continue
}
let allVirtualBlocksExecuted = true
const parallelNodes = (parallel as any).nodes || []
for (const nodeId of parallelNodes) {
for (let i = 0; i < parallelState.parallelCount; i++) {
const virtualBlockId = `${nodeId}_parallel_${parallelId}_iteration_${i}`
if (!context.executedBlocks.has(virtualBlockId)) {
allVirtualBlocksExecuted = false
break
}
}
if (!allVirtualBlocksExecuted) break
}
if (allVirtualBlocksExecuted && !context.completedLoops.has(parallelId)) {
context.executedBlocks.delete(parallelId)
context.activeExecutionPath.add(parallelId)
for (const nodeId of parallelNodes) {
context.activeExecutionPath.delete(nodeId)
}
}
}
}),
createVirtualBlockInstances: vi.fn().mockImplementation((block, parallelId, state) => {
const instances = []
for (let i = 0; i < state.parallelCount; i++) {
instances.push(`${block.id}_parallel_${parallelId}_iteration_${i}`)
}
return instances
}),
setupIterationContext: vi.fn(),
storeIterationResult: vi.fn(),
initializeParallel: vi.fn(),
getIterationItem: vi.fn(),
areAllVirtualBlocksExecuted: vi
.fn()
.mockImplementation((parallelId, parallel, executedBlocks, state, context) => {
// Simple mock implementation - check all blocks (ignoring conditional routing for tests)
for (const nodeId of parallel.nodes) {
for (let i = 0; i < state.parallelCount; i++) {
const virtualBlockId = `${nodeId}_parallel_${parallelId}_iteration_${i}`
if (!executedBlocks.has(virtualBlockId)) {
return false
}
}
}
return true
}),
}
}),
})
/**
* Setup function block handler that executes code
*/
export const createFunctionBlockHandler = vi.fn().mockImplementation(() => ({
canHandle: (block: any) => block.metadata?.id === 'function',
execute: vi.fn().mockImplementation(async (block, inputs) => {
return {
result: inputs.code ? new Function(inputs.code)() : { key: inputs.key, value: inputs.value },
stdout: '',
}
}),
}))
/**
* Create a custom parallel block handler for testing
*/
export const createParallelBlockHandler = vi.fn().mockImplementation(() => {
return {
canHandle: (block: any) => block.metadata?.id === 'parallel',
execute: vi.fn().mockImplementation(async (block, inputs, context) => {
const parallelId = block.id
const parallel = context.workflow?.parallels?.[parallelId]
if (!parallel) {
throw new Error('Parallel configuration not found')
}
if (!context.parallelExecutions) {
context.parallelExecutions = new Map()
}
let parallelState = context.parallelExecutions.get(parallelId)
if (!parallelState) {
// First execution - initialize
const distributionItems = parallel.distribution || []
const parallelCount = Array.isArray(distributionItems)
? distributionItems.length
: typeof distributionItems === 'object'
? Object.keys(distributionItems).length
: 1
parallelState = {
parallelCount,
distributionItems,
completedExecutions: 0,
executionResults: new Map(),
activeIterations: new Set(),
}
context.parallelExecutions.set(parallelId, parallelState)
if (distributionItems) {
context.loopItems.set(`${parallelId}_items`, distributionItems)
}
// Activate child nodes
const connections =
context.workflow?.connections.filter(
(conn: any) =>
conn.source === parallelId && conn.sourceHandle === 'parallel-start-source'
) || []
for (const conn of connections) {
context.activeExecutionPath.add(conn.target)
}
return {
parallelId,
parallelCount,
distributionType: 'distributed',
started: true,
message: `Initialized ${parallelCount} parallel executions`,
}
}
// Check completion
const allCompleted = parallel.nodes.every((nodeId: string) => {
for (let i = 0; i < parallelState.parallelCount; i++) {
const virtualBlockId = `${nodeId}_parallel_${parallelId}_iteration_${i}`
if (!context.executedBlocks.has(virtualBlockId)) {
return false
}
}
return true
})
if (allCompleted) {
context.completedLoops.add(parallelId)
// Activate end connections
const endConnections =
context.workflow?.connections.filter(
(conn: any) => conn.source === parallelId && conn.sourceHandle === 'parallel-end-source'
) || []
for (const conn of endConnections) {
context.activeExecutionPath.add(conn.target)
}
return {
parallelId,
parallelCount: parallelState.parallelCount,
completed: true,
message: `Completed all ${parallelState.parallelCount} executions`,
}
}
return {
parallelId,
parallelCount: parallelState.parallelCount,
waiting: true,
message: 'Waiting for iterations to complete',
}
}),
}
})
/**
* Create an input resolver mock that handles parallel references
*/
export const createParallelInputResolver = (distributionData: any) => ({
InputResolver: vi.fn().mockImplementation(() => ({
resolveInputs: vi.fn().mockImplementation((block, context) => {
if (block.metadata?.id === 'function') {
const virtualBlockId = context.currentVirtualBlockId
if (virtualBlockId && context.parallelBlockMapping) {
const mapping = context.parallelBlockMapping.get(virtualBlockId)
if (mapping) {
if (Array.isArray(distributionData)) {
const currentItem = distributionData[mapping.iterationIndex]
const currentIndex = mapping.iterationIndex
return {
code: `return { item: "${currentItem}", index: ${currentIndex} }`,
}
}
if (typeof distributionData === 'object') {
const entries = Object.entries(distributionData)
const [key, value] = entries[mapping.iterationIndex]
return {
code: `return { key: "${key}", value: "${value}" }`,
}
}
}
}
}
return {}
}),
})),
})
/**
* Create a workflow with parallel blocks for testing
*/
export const createWorkflowWithParallelArray = (
items: any[] = ['apple', 'banana', 'cherry']
): SerializedWorkflow => ({
version: '2.0',
blocks: [
{
id: 'starter',
position: { x: 0, y: 0 },
metadata: { id: 'starter', name: 'Start' },
config: { tool: 'starter', params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'parallel-1',
position: { x: 100, y: 0 },
metadata: { id: 'parallel', name: 'Test Parallel' },
config: { tool: 'parallel', params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'function-1',
position: { x: 200, y: 0 },
metadata: { id: 'function', name: 'Process Item' },
config: {
tool: 'function',
params: {
code: 'return { item: <parallel.currentItem>, index: <parallel.index> }',
},
},
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'endpoint',
position: { x: 300, y: 0 },
metadata: { id: 'generic', name: 'End' },
config: { tool: 'generic', params: {} },
inputs: {},
outputs: {},
enabled: true,
},
],
connections: [
{ source: 'starter', target: 'parallel-1' },
{ source: 'parallel-1', target: 'function-1', sourceHandle: 'parallel-start-source' },
{ source: 'parallel-1', target: 'endpoint', sourceHandle: 'parallel-end-source' },
],
loops: {},
parallels: {
'parallel-1': {
id: 'parallel-1',
nodes: ['function-1'],
distribution: items,
},
},
})
/**
* Create a workflow with parallel blocks for object distribution
*/
export const createWorkflowWithParallelObject = (
items: Record<string, any> = { first: 'alpha', second: 'beta', third: 'gamma' }
): SerializedWorkflow => ({
version: '2.0',
blocks: [
{
id: 'starter',
position: { x: 0, y: 0 },
metadata: { id: 'starter', name: 'Start' },
config: { tool: 'starter', params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'parallel-1',
position: { x: 100, y: 0 },
metadata: { id: 'parallel', name: 'Test Parallel' },
config: { tool: 'parallel', params: {} },
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'function-1',
position: { x: 200, y: 0 },
metadata: { id: 'function', name: 'Process Entry' },
config: {
tool: 'function',
params: {
code: 'return { key: <parallel.currentItem.key>, value: <parallel.currentItem.value> }',
},
},
inputs: {},
outputs: {},
enabled: true,
},
{
id: 'endpoint',
position: { x: 300, y: 0 },
metadata: { id: 'generic', name: 'End' },
config: { tool: 'generic', params: {} },
inputs: {},
outputs: {},
enabled: true,
},
],
connections: [
{ source: 'starter', target: 'parallel-1' },
{ source: 'parallel-1', target: 'function-1', sourceHandle: 'parallel-start-source' },
{ source: 'parallel-1', target: 'endpoint', sourceHandle: 'parallel-end-source' },
],
loops: {},
parallels: {
'parallel-1': {
id: 'parallel-1',
nodes: ['function-1'],
distribution: items,
},
},
})
/**
* Mock all modules needed for parallel tests
*/
export const setupParallelTestMocks = (options?: {
distributionData?: any
maxParallelChecks?: number
}) => {
setupStoreMocks()
setupExecutorCoreMocks()
vi.doMock('@/executor/parallels', () =>
createParallelManagerMock({
maxChecks: options?.maxParallelChecks,
})
)
vi.doMock('@/executor/loops', () => createLoopManagerMock())
}
/**
* Sets up all standard mocks for executor tests
*/
export const setupAllMocks = (options?: {
isDebugging?: boolean
consoleAddFn?: ReturnType<typeof vi.fn>
consoleUpdateFn?: ReturnType<typeof vi.fn>
}) => {
setupHandlerMocks()
const storeMocks = setupStoreMocks(options)
setupExecutorCoreMocks()
return storeMocks
}

Some files were not shown because too many files have changed in this diff Show More