mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-16 02:18:06 -05:00
Compare commits
17 Commits
v0.5.60
...
feat/canon
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2d74893e7 | ||
|
|
bfbfd45bc1 | ||
|
|
740c64aabd | ||
|
|
975e9f3510 | ||
|
|
14e5df872a | ||
|
|
879cdf1d44 | ||
|
|
95f0f4e45e | ||
|
|
d748a82645 | ||
|
|
b464d70cda | ||
|
|
87280c8a3d | ||
|
|
8d4d865569 | ||
|
|
6f469a7f37 | ||
|
|
a35f6eca03 | ||
|
|
1cc489e544 | ||
|
|
e499cc4f82 | ||
|
|
5e44357b9f | ||
|
|
debcd76019 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"pages": ["index", "basics", "api", "form", "logging", "costs"]
|
||||
"pages": ["index", "basics", "api", "logging", "costs"]
|
||||
}
|
||||
|
||||
@@ -43,6 +43,27 @@ In Sim, the Slack integration enables your agents to programmatically interact w
|
||||
- **Download files**: Retrieve files shared in Slack channels for processing or archival
|
||||
|
||||
This allows for powerful automation scenarios such as sending notifications with dynamic updates, managing conversational flows with editable status messages, acknowledging important messages with reactions, and maintaining clean channels by removing outdated bot messages. Your agents can deliver timely information, update messages as workflows progress, create collaborative documents, or alert team members when attention is needed. This integration bridges the gap between your AI workflows and your team's communication, ensuring everyone stays informed with accurate, up-to-date information. By connecting Sim with Slack, you can create agents that keep your team updated with relevant information at the right time, enhance collaboration by sharing and updating insights automatically, and reduce the need for manual status updates—all while leveraging your existing Slack workspace where your team already communicates.
|
||||
|
||||
## Getting Started
|
||||
|
||||
To connect Slack to your Sim workflows:
|
||||
|
||||
1. Sign up or log in at [sim.ai](https://sim.ai)
|
||||
2. Create a new workflow or open an existing one
|
||||
3. Drag a **Slack** block onto your canvas
|
||||
4. Click the credential selector and choose **Connect**
|
||||
5. Authorize Sim to access your Slack workspace
|
||||
6. Select your target channel or user
|
||||
|
||||
Once connected, you can use any of the Slack operations listed below.
|
||||
|
||||
## AI-Generated Content
|
||||
|
||||
Sim workflows may use AI models to generate messages and responses sent to Slack. AI-generated content may be inaccurate or contain errors. Always review automated outputs, especially for critical communications.
|
||||
|
||||
## Need Help?
|
||||
|
||||
If you encounter issues with the Slack integration, contact us at [help@sim.ai](mailto:help@sim.ai)
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
|
||||
11
apps/sim/app/_shell/providers/tooltip-provider.tsx
Normal file
11
apps/sim/app/_shell/providers/tooltip-provider.tsx
Normal 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>
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -14,8 +14,7 @@ import {
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { REFERENCE } from '@/executor/constants'
|
||||
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
|
||||
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
||||
import { executeTool } from '@/tools'
|
||||
import { getTool, resolveToolId } from '@/tools/utils'
|
||||
|
||||
@@ -28,45 +27,6 @@ const ExecuteToolSchema = z.object({
|
||||
workflowId: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolves all {{ENV_VAR}} references in a value recursively
|
||||
* Works with strings, arrays, and objects
|
||||
*/
|
||||
function resolveEnvVarReferences(value: any, envVars: Record<string, string>): any {
|
||||
if (typeof value === 'string') {
|
||||
// Check for exact match: entire string is "{{VAR_NAME}}"
|
||||
const exactMatchPattern = new RegExp(
|
||||
`^\\${REFERENCE.ENV_VAR_START}([^}]+)\\${REFERENCE.ENV_VAR_END}$`
|
||||
)
|
||||
const exactMatch = exactMatchPattern.exec(value)
|
||||
if (exactMatch) {
|
||||
const envVarName = exactMatch[1].trim()
|
||||
return envVars[envVarName] ?? value
|
||||
}
|
||||
|
||||
// Check for embedded references: "prefix {{VAR}} suffix"
|
||||
const envVarPattern = createEnvVarPattern()
|
||||
return value.replace(envVarPattern, (match, varName) => {
|
||||
const trimmedName = varName.trim()
|
||||
return envVars[trimmedName] ?? match
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => resolveEnvVarReferences(item, envVars))
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const resolved: Record<string, any> = {}
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
resolved[key] = resolveEnvVarReferences(val, envVars)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const tracker = createRequestTracker()
|
||||
|
||||
@@ -145,7 +105,17 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// Build execution params starting with LLM-provided arguments
|
||||
// Resolve all {{ENV_VAR}} references in the arguments
|
||||
const executionParams: Record<string, any> = resolveEnvVarReferences(toolArgs, decryptedEnvVars)
|
||||
const executionParams: Record<string, any> = resolveEnvVarReferences(
|
||||
toolArgs,
|
||||
decryptedEnvVars,
|
||||
{
|
||||
resolveExactMatch: true,
|
||||
allowEmbedded: true,
|
||||
trimKeys: true,
|
||||
onMissing: 'keep',
|
||||
deep: true,
|
||||
}
|
||||
) as Record<string, any>
|
||||
|
||||
logger.info(`[${tracker.requestId}] Resolved env var references in arguments`, {
|
||||
toolName,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
|
||||
import {
|
||||
createEnvVarPattern,
|
||||
createWorkflowVariablePattern,
|
||||
resolveEnvVarReferences,
|
||||
} from '@/executor/utils/reference-validation'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
@@ -479,9 +480,29 @@ function resolveEnvironmentVariables(
|
||||
const replacements: Array<{ match: string; index: number; varName: string; varValue: string }> =
|
||||
[]
|
||||
|
||||
const resolverVars: Record<string, string> = {}
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
resolverVars[key] = String(value)
|
||||
}
|
||||
})
|
||||
Object.entries(envVars).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
resolverVars[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
while ((match = regex.exec(code)) !== null) {
|
||||
const varName = match[1].trim()
|
||||
const varValue = envVars[varName] || params[varName] || ''
|
||||
const resolved = resolveEnvVarReferences(match[0], resolverVars, {
|
||||
allowEmbedded: true,
|
||||
resolveExactMatch: true,
|
||||
trimKeys: true,
|
||||
onMissing: 'empty',
|
||||
deep: false,
|
||||
})
|
||||
const varValue =
|
||||
typeof resolved === 'string' ? resolved : resolved == null ? '' : String(resolved)
|
||||
replacements.push({
|
||||
match: match[0],
|
||||
index: match.index,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -5,8 +5,7 @@ import { McpClient } from '@/lib/mcp/client'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import type { McpServerConfig, McpTransport } from '@/lib/mcp/types'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import { REFERENCE } from '@/executor/constants'
|
||||
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
|
||||
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
||||
|
||||
const logger = createLogger('McpServerTestAPI')
|
||||
|
||||
@@ -24,22 +23,23 @@ function isUrlBasedTransport(transport: McpTransport): boolean {
|
||||
* Resolve environment variables in strings
|
||||
*/
|
||||
function resolveEnvVars(value: string, envVars: Record<string, string>): string {
|
||||
const envVarPattern = createEnvVarPattern()
|
||||
const envMatches = value.match(envVarPattern)
|
||||
if (!envMatches) return value
|
||||
const missingVars: string[] = []
|
||||
const resolvedValue = resolveEnvVarReferences(value, envVars, {
|
||||
allowEmbedded: true,
|
||||
resolveExactMatch: true,
|
||||
trimKeys: true,
|
||||
onMissing: 'keep',
|
||||
deep: false,
|
||||
missingKeys: missingVars,
|
||||
}) as string
|
||||
|
||||
let resolvedValue = value
|
||||
for (const match of envMatches) {
|
||||
const envKey = match.slice(REFERENCE.ENV_VAR_START.length, -REFERENCE.ENV_VAR_END.length).trim()
|
||||
const envValue = envVars[envKey]
|
||||
|
||||
if (envValue === undefined) {
|
||||
if (missingVars.length > 0) {
|
||||
const uniqueMissing = Array.from(new Set(missingVars))
|
||||
uniqueMissing.forEach((envKey) => {
|
||||
logger.warn(`Environment variable "${envKey}" not found in MCP server test`)
|
||||
continue
|
||||
}
|
||||
|
||||
resolvedValue = resolvedValue.replace(match, envValue)
|
||||
})
|
||||
}
|
||||
|
||||
return resolvedValue
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
invitation,
|
||||
member,
|
||||
organization,
|
||||
permissionGroup,
|
||||
permissionGroupMember,
|
||||
permissions,
|
||||
subscription as subscriptionTable,
|
||||
user,
|
||||
@@ -17,6 +19,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasAccessControlAccess } from '@/lib/billing'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
@@ -382,6 +385,47 @@ export async function PUT(
|
||||
// Don't fail the whole invitation acceptance due to this
|
||||
}
|
||||
|
||||
// Auto-assign to permission group if one has autoAddNewMembers enabled
|
||||
try {
|
||||
const hasAccessControl = await hasAccessControlAccess(session.user.id)
|
||||
if (hasAccessControl) {
|
||||
const [autoAddGroup] = await tx
|
||||
.select({ id: permissionGroup.id, name: permissionGroup.name })
|
||||
.from(permissionGroup)
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.organizationId, organizationId),
|
||||
eq(permissionGroup.autoAddNewMembers, true)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (autoAddGroup) {
|
||||
await tx.insert(permissionGroupMember).values({
|
||||
id: randomUUID(),
|
||||
permissionGroupId: autoAddGroup.id,
|
||||
userId: session.user.id,
|
||||
assignedBy: null,
|
||||
assignedAt: new Date(),
|
||||
})
|
||||
|
||||
logger.info('Auto-assigned new member to permission group', {
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
permissionGroupId: autoAddGroup.id,
|
||||
permissionGroupName: autoAddGroup.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to auto-assign user to permission group', {
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
error,
|
||||
})
|
||||
// Don't fail the whole invitation acceptance due to this
|
||||
}
|
||||
|
||||
const linkedWorkspaceInvitations = await tx
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
|
||||
@@ -25,12 +25,19 @@ const configSchema = z.object({
|
||||
disableMcpTools: z.boolean().optional(),
|
||||
disableCustomTools: z.boolean().optional(),
|
||||
hideTemplates: z.boolean().optional(),
|
||||
disableInvitations: z.boolean().optional(),
|
||||
hideDeployApi: z.boolean().optional(),
|
||||
hideDeployMcp: z.boolean().optional(),
|
||||
hideDeployA2a: z.boolean().optional(),
|
||||
hideDeployChatbot: z.boolean().optional(),
|
||||
hideDeployTemplate: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const updateSchema = z.object({
|
||||
name: z.string().trim().min(1).max(100).optional(),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
config: configSchema.optional(),
|
||||
autoAddNewMembers: z.boolean().optional(),
|
||||
})
|
||||
|
||||
async function getPermissionGroupWithAccess(groupId: string, userId: string) {
|
||||
@@ -44,6 +51,7 @@ async function getPermissionGroupWithAccess(groupId: string, userId: string) {
|
||||
createdBy: permissionGroup.createdBy,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
updatedAt: permissionGroup.updatedAt,
|
||||
autoAddNewMembers: permissionGroup.autoAddNewMembers,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(eq(permissionGroup.id, groupId))
|
||||
@@ -140,11 +148,27 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
? { ...currentConfig, ...updates.config }
|
||||
: currentConfig
|
||||
|
||||
// If setting autoAddNewMembers to true, unset it on other groups in the org first
|
||||
if (updates.autoAddNewMembers === true) {
|
||||
await db
|
||||
.update(permissionGroup)
|
||||
.set({ autoAddNewMembers: false, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.organizationId, result.group.organizationId),
|
||||
eq(permissionGroup.autoAddNewMembers, true)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(permissionGroup)
|
||||
.set({
|
||||
...(updates.name !== undefined && { name: updates.name }),
|
||||
...(updates.description !== undefined && { description: updates.description }),
|
||||
...(updates.autoAddNewMembers !== undefined && {
|
||||
autoAddNewMembers: updates.autoAddNewMembers,
|
||||
}),
|
||||
config: newConfig,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
@@ -26,6 +26,12 @@ const configSchema = z.object({
|
||||
disableMcpTools: z.boolean().optional(),
|
||||
disableCustomTools: z.boolean().optional(),
|
||||
hideTemplates: z.boolean().optional(),
|
||||
disableInvitations: z.boolean().optional(),
|
||||
hideDeployApi: z.boolean().optional(),
|
||||
hideDeployMcp: z.boolean().optional(),
|
||||
hideDeployA2a: z.boolean().optional(),
|
||||
hideDeployChatbot: z.boolean().optional(),
|
||||
hideDeployTemplate: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const createSchema = z.object({
|
||||
@@ -33,6 +39,7 @@ const createSchema = z.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
config: configSchema.optional(),
|
||||
autoAddNewMembers: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export async function GET(req: Request) {
|
||||
@@ -68,6 +75,7 @@ export async function GET(req: Request) {
|
||||
createdBy: permissionGroup.createdBy,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
updatedAt: permissionGroup.updatedAt,
|
||||
autoAddNewMembers: permissionGroup.autoAddNewMembers,
|
||||
creatorName: user.name,
|
||||
creatorEmail: user.email,
|
||||
})
|
||||
@@ -111,7 +119,8 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { organizationId, name, description, config } = createSchema.parse(body)
|
||||
const { organizationId, name, description, config, autoAddNewMembers } =
|
||||
createSchema.parse(body)
|
||||
|
||||
const membership = await db
|
||||
.select({ id: member.id, role: member.role })
|
||||
@@ -154,6 +163,19 @@ export async function POST(req: Request) {
|
||||
...config,
|
||||
}
|
||||
|
||||
// If autoAddNewMembers is true, unset it on any existing groups first
|
||||
if (autoAddNewMembers) {
|
||||
await db
|
||||
.update(permissionGroup)
|
||||
.set({ autoAddNewMembers: false, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.organizationId, organizationId),
|
||||
eq(permissionGroup.autoAddNewMembers, true)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const newGroup = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -164,6 +186,7 @@ export async function POST(req: Request) {
|
||||
createdBy: session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
autoAddNewMembers: autoAddNewMembers || false,
|
||||
}
|
||||
|
||||
await db.insert(permissionGroup).values(newGroup)
|
||||
|
||||
@@ -93,6 +93,11 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
nextRunAt: 'nextRunAt',
|
||||
lastQueuedAt: 'lastQueuedAt',
|
||||
},
|
||||
workflow: {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
workspaceId: 'workspaceId',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -170,6 +175,11 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
nextRunAt: 'nextRunAt',
|
||||
lastQueuedAt: 'lastQueuedAt',
|
||||
},
|
||||
workflow: {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
workspaceId: 'workspaceId',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -229,6 +239,11 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
nextRunAt: 'nextRunAt',
|
||||
lastQueuedAt: 'lastQueuedAt',
|
||||
},
|
||||
workflow: {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
workspaceId: 'workspaceId',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -311,6 +326,11 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
nextRunAt: 'nextRunAt',
|
||||
lastQueuedAt: 'lastQueuedAt',
|
||||
},
|
||||
workflow: {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
workspaceId: 'workspaceId',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -152,7 +152,6 @@ export async function POST(
|
||||
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
|
||||
requestId,
|
||||
path,
|
||||
executionTarget: 'deployed',
|
||||
})
|
||||
responses.push(response)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -110,6 +110,7 @@ type AsyncExecutionParams = {
|
||||
userId: string
|
||||
input: any
|
||||
triggerType: CoreTriggerType
|
||||
preflighted?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +133,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
|
||||
userId,
|
||||
input,
|
||||
triggerType,
|
||||
preflighted: params.preflighted,
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -264,6 +266,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
requestId
|
||||
)
|
||||
|
||||
const shouldPreflightEnvVars = isAsyncMode && isTriggerDevEnabled
|
||||
const preprocessResult = await preprocessExecution({
|
||||
workflowId,
|
||||
userId,
|
||||
@@ -272,6 +275,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
requestId,
|
||||
checkDeployment: !shouldUseDraftState,
|
||||
loggingSession,
|
||||
preflightEnvVars: shouldPreflightEnvVars,
|
||||
useDraftState: shouldUseDraftState,
|
||||
envUserId: isClientSession ? userId : undefined,
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
@@ -303,6 +309,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
userId: actorUserId,
|
||||
input,
|
||||
triggerType: loggingTriggerType,
|
||||
preflighted: shouldPreflightEnvVars,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { BlockInfo, BlockMenuProps } from './block-menu'
|
||||
export { BlockMenu } from './block-menu'
|
||||
@@ -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 />
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CanvasMenuProps } from './canvas-menu'
|
||||
export { CanvasMenu } from './canvas-menu'
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export { BlockContextMenu } from './block-context-menu'
|
||||
export { PaneContextMenu } from './pane-context-menu'
|
||||
export type {
|
||||
BlockContextMenuProps,
|
||||
ContextMenuBlockInfo,
|
||||
ContextMenuPosition,
|
||||
PaneContextMenuProps,
|
||||
} from './types'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]' : ''
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/w/components/si
|
||||
import { startsWithUuid } from '@/executor/constants'
|
||||
import { useApiKeys } from '@/hooks/queries/api-keys'
|
||||
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSettingsModalStore } from '@/stores/modals/settings/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -113,16 +114,12 @@ export function DeployModal({
|
||||
const [existingChat, setExistingChat] = useState<ExistingChat | null>(null)
|
||||
const [isLoadingChat, setIsLoadingChat] = useState(false)
|
||||
|
||||
const [formSubmitting, setFormSubmitting] = useState(false)
|
||||
const [formExists, setFormExists] = useState(false)
|
||||
const [isFormValid, setIsFormValid] = useState(false)
|
||||
|
||||
const [chatSuccess, setChatSuccess] = useState(false)
|
||||
const [formSuccess, setFormSuccess] = useState(false)
|
||||
|
||||
const [isCreateKeyModalOpen, setIsCreateKeyModalOpen] = useState(false)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const canManageWorkspaceKeys = userPermissions.canAdmin
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '')
|
||||
const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings(
|
||||
workflowWorkspaceId || ''
|
||||
@@ -189,6 +186,7 @@ export function DeployModal({
|
||||
useEffect(() => {
|
||||
if (open && workflowId) {
|
||||
setActiveTab('general')
|
||||
setApiDeployError(null)
|
||||
fetchChatDeploymentInfo()
|
||||
}
|
||||
}, [open, workflowId, fetchChatDeploymentInfo])
|
||||
@@ -507,6 +505,7 @@ export function DeployModal({
|
||||
const handleCloseModal = () => {
|
||||
setIsSubmitting(false)
|
||||
setChatSubmitting(false)
|
||||
setApiDeployError(null)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
@@ -516,12 +515,6 @@ export function DeployModal({
|
||||
setTimeout(() => setChatSuccess(false), 2000)
|
||||
}
|
||||
|
||||
const handleFormDeployed = async () => {
|
||||
await handlePostDeploymentUpdate()
|
||||
setFormSuccess(true)
|
||||
setTimeout(() => setFormSuccess(false), 2000)
|
||||
}
|
||||
|
||||
const handlePostDeploymentUpdate = async () => {
|
||||
if (!workflowId) return
|
||||
|
||||
@@ -630,17 +623,6 @@ export function DeployModal({
|
||||
deleteTrigger?.click()
|
||||
}, [])
|
||||
|
||||
const handleFormFormSubmit = useCallback(() => {
|
||||
const form = document.getElementById('form-deploy-form') as HTMLFormElement
|
||||
form?.requestSubmit()
|
||||
}, [])
|
||||
|
||||
const handleFormDelete = useCallback(() => {
|
||||
const form = document.getElementById('form-deploy-form')
|
||||
const deleteTrigger = form?.querySelector('[data-delete-trigger]') as HTMLButtonElement
|
||||
deleteTrigger?.click()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={handleCloseModal}>
|
||||
@@ -654,15 +636,31 @@ export function DeployModal({
|
||||
>
|
||||
<ModalTabsList activeValue={activeTab}>
|
||||
<ModalTabsTrigger value='general'>General</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='mcp'>MCP</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='a2a'>A2A</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='chat'>Chat</ModalTabsTrigger>
|
||||
{!permissionConfig.hideDeployApi && (
|
||||
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
|
||||
)}
|
||||
{!permissionConfig.hideDeployMcp && (
|
||||
<ModalTabsTrigger value='mcp'>MCP</ModalTabsTrigger>
|
||||
)}
|
||||
{!permissionConfig.hideDeployA2a && (
|
||||
<ModalTabsTrigger value='a2a'>A2A</ModalTabsTrigger>
|
||||
)}
|
||||
{!permissionConfig.hideDeployChatbot && (
|
||||
<ModalTabsTrigger value='chat'>Chat</ModalTabsTrigger>
|
||||
)}
|
||||
{/* <ModalTabsTrigger value='form'>Form</ModalTabsTrigger> */}
|
||||
<ModalTabsTrigger value='template'>Template</ModalTabsTrigger>
|
||||
{!permissionConfig.hideDeployTemplate && (
|
||||
<ModalTabsTrigger value='template'>Template</ModalTabsTrigger>
|
||||
)}
|
||||
</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}
|
||||
|
||||
@@ -2,16 +2,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller'
|
||||
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'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/**
|
||||
* Constants for ComboBox component behavior
|
||||
@@ -91,15 +94,24 @@ export function ComboBox({
|
||||
// Dependency tracking for fetchOptions
|
||||
const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn])
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
const blockState = useWorkflowStore((state) => state.blocks[blockId])
|
||||
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||
const dependencyValues = useSubBlockStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
const blockValues = workflowValues[blockId] || {}
|
||||
return dependsOnFields.map((depKey) => blockValues[depKey] ?? null)
|
||||
return dependsOnFields.map((depKey) =>
|
||||
resolveDependencyValue(depKey, blockValues, canonicalIndex, canonicalModeOverrides)
|
||||
)
|
||||
},
|
||||
[dependsOnFields, activeWorkflowId, blockId]
|
||||
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/**
|
||||
* Dropdown option type - can be a simple string or an object with label, id, and optional icon
|
||||
@@ -89,15 +92,24 @@ export function Dropdown({
|
||||
const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn])
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
const blockState = useWorkflowStore((state) => state.blocks[blockId])
|
||||
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||
const dependencyValues = useSubBlockStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
const blockValues = workflowValues[blockId] || {}
|
||||
return dependsOnFields.map((depKey) => blockValues[depKey] ?? null)
|
||||
return dependsOnFields.map((depKey) =>
|
||||
resolveDependencyValue(depKey, blockValues, canonicalIndex, canonicalModeOverrides)
|
||||
)
|
||||
},
|
||||
[dependsOnFields, activeWorkflowId, blockId]
|
||||
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -4,15 +4,19 @@ import { useMemo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { isDependency } from '@/blocks/utils'
|
||||
import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
interface FileSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -42,21 +46,59 @@ export function FileSelectorInput({
|
||||
previewContextValues,
|
||||
})
|
||||
|
||||
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
|
||||
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
|
||||
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
|
||||
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
|
||||
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
|
||||
const [siteIdValueFromStore] = useSubBlockValue(blockId, 'siteId')
|
||||
const [collectionIdValueFromStore] = useSubBlockValue(blockId, 'collectionId')
|
||||
const blockState = useWorkflowStore((state) => state.blocks[blockId])
|
||||
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||
|
||||
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const blockValues = useSubBlockStore((state) => {
|
||||
if (!activeWorkflowId) return {}
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {}
|
||||
})
|
||||
|
||||
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
|
||||
|
||||
const connectedCredential = previewContextValues?.credential ?? blockValues.credential
|
||||
const domainValue = previewContextValues?.domain ?? domainValueFromStore
|
||||
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
|
||||
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
|
||||
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
|
||||
const siteIdValue = previewContextValues?.siteId ?? siteIdValueFromStore
|
||||
const collectionIdValue = previewContextValues?.collectionId ?? collectionIdValueFromStore
|
||||
|
||||
const teamIdValue = useMemo(
|
||||
() =>
|
||||
previewContextValues?.teamId ??
|
||||
resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides),
|
||||
[previewContextValues?.teamId, blockValues, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
|
||||
const siteIdValue = useMemo(
|
||||
() =>
|
||||
previewContextValues?.siteId ??
|
||||
resolveDependencyValue('siteId', blockValues, canonicalIndex, canonicalModeOverrides),
|
||||
[previewContextValues?.siteId, blockValues, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
|
||||
const collectionIdValue = useMemo(
|
||||
() =>
|
||||
previewContextValues?.collectionId ??
|
||||
resolveDependencyValue('collectionId', blockValues, canonicalIndex, canonicalModeOverrides),
|
||||
[previewContextValues?.collectionId, blockValues, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
|
||||
const projectIdValue = useMemo(
|
||||
() =>
|
||||
previewContextValues?.projectId ??
|
||||
resolveDependencyValue('projectId', blockValues, canonicalIndex, canonicalModeOverrides),
|
||||
[previewContextValues?.projectId, blockValues, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
|
||||
const planIdValue = useMemo(
|
||||
() =>
|
||||
previewContextValues?.planId ??
|
||||
resolveDependencyValue('planId', blockValues, canonicalIndex, canonicalModeOverrides),
|
||||
[previewContextValues?.planId, blockValues, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
|
||||
const normalizedCredentialId =
|
||||
typeof connectedCredential === 'string'
|
||||
@@ -65,7 +107,6 @@ export function FileSelectorInput({
|
||||
? ((connectedCredential as Record<string, any>).id ?? '')
|
||||
: ''
|
||||
|
||||
// Derive provider from serviceId using OAuth config (same pattern as credential-selector)
|
||||
const serviceId = subBlock.serviceId || ''
|
||||
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,14 +4,17 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
interface ProjectSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -32,21 +35,36 @@ export function ProjectSelectorInput({
|
||||
previewValue,
|
||||
previewContextValues,
|
||||
}: ProjectSelectorInputProps) {
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const params = useParams()
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
|
||||
// Use the proper hook to get the current value and setter
|
||||
const [storeValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
|
||||
const [linearTeamIdFromStore] = useSubBlockValue(blockId, 'teamId')
|
||||
const [jiraDomainFromStore] = useSubBlockValue(blockId, 'domain')
|
||||
|
||||
// Use previewContextValues if provided (for tools inside agent blocks), otherwise use store values
|
||||
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const linearTeamId = previewContextValues?.teamId ?? linearTeamIdFromStore
|
||||
const blockState = useWorkflowStore((state) => state.blocks[blockId])
|
||||
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||
|
||||
const blockValues = useSubBlockStore((state) => {
|
||||
if (!activeWorkflowId) return {}
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {}
|
||||
})
|
||||
|
||||
const connectedCredential = previewContextValues?.credential ?? blockValues.credential
|
||||
const jiraDomain = previewContextValues?.domain ?? jiraDomainFromStore
|
||||
|
||||
// Derive provider from serviceId using OAuth config
|
||||
const linearTeamId = useMemo(
|
||||
() =>
|
||||
previewContextValues?.teamId ??
|
||||
resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides),
|
||||
[previewContextValues?.teamId, blockValues, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
|
||||
const serviceId = subBlock.serviceId || ''
|
||||
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
||||
|
||||
@@ -54,7 +72,6 @@ export function ProjectSelectorInput({
|
||||
effectiveProviderId,
|
||||
(connectedCredential as string) || ''
|
||||
)
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
|
||||
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
|
||||
disabled,
|
||||
@@ -62,12 +79,8 @@ export function ProjectSelectorInput({
|
||||
previewContextValues,
|
||||
})
|
||||
|
||||
// Jira/Discord upstream fields - use values from previewContextValues or store
|
||||
const domain = (jiraDomain as string) || ''
|
||||
|
||||
// Verify Jira credential belongs to current user; if not, treat as absent
|
||||
|
||||
// Get the current value from the store or prop value if in preview mode
|
||||
useEffect(() => {
|
||||
if (isPreview && previewValue !== undefined) {
|
||||
setSelectedProjectId(previewValue)
|
||||
|
||||
@@ -4,14 +4,17 @@ import { useMemo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
interface SheetSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -41,16 +44,32 @@ export function SheetSelectorInput({
|
||||
previewContextValues,
|
||||
})
|
||||
|
||||
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
|
||||
const [spreadsheetIdFromStore] = useSubBlockValue(blockId, 'spreadsheetId')
|
||||
const [manualSpreadsheetIdFromStore] = useSubBlockValue(blockId, 'manualSpreadsheetId')
|
||||
const blockState = useWorkflowStore((state) => state.blocks[blockId])
|
||||
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||
|
||||
const blockValues = useSubBlockStore((state) => {
|
||||
if (!activeWorkflowId) return {}
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {}
|
||||
})
|
||||
|
||||
const connectedCredentialFromStore = blockValues.credential
|
||||
|
||||
const spreadsheetIdFromStore = useMemo(
|
||||
() =>
|
||||
resolveDependencyValue('spreadsheetId', blockValues, canonicalIndex, canonicalModeOverrides),
|
||||
[blockValues, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
|
||||
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const spreadsheetId =
|
||||
previewContextValues?.spreadsheetId ??
|
||||
spreadsheetIdFromStore ??
|
||||
previewContextValues?.manualSpreadsheetId ??
|
||||
manualSpreadsheetIdFromStore
|
||||
const spreadsheetId = previewContextValues
|
||||
? (previewContextValues.spreadsheetId ?? previewContextValues.manualSpreadsheetId)
|
||||
: spreadsheetIdFromStore
|
||||
|
||||
const normalizedCredentialId =
|
||||
typeof connectedCredential === 'string'
|
||||
@@ -61,7 +80,6 @@ export function SheetSelectorInput({
|
||||
|
||||
const normalizedSpreadsheetId = typeof spreadsheetId === 'string' ? spreadsheetId.trim() : ''
|
||||
|
||||
// Derive provider from serviceId using OAuth config
|
||||
const serviceId = subBlock.serviceId || ''
|
||||
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQuery } 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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
isNonEmptyValue,
|
||||
resolveDependencyValue,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
type DependsOnConfig = string[] | { all?: string[]; any?: string[] }
|
||||
|
||||
@@ -50,6 +57,13 @@ export function useDependsOnGate(
|
||||
const previewContextValues = opts?.previewContextValues
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
const blockState = useWorkflowStore((state) => state.blocks[blockId])
|
||||
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||
|
||||
// Parse dependsOn config to get all/any field lists
|
||||
const { allFields, anyFields, allDependsOnFields } = useMemo(
|
||||
@@ -91,7 +105,13 @@ export function useDependsOnGate(
|
||||
if (previewContextValues) {
|
||||
const map: Record<string, unknown> = {}
|
||||
for (const key of allDependsOnFields) {
|
||||
map[key] = normalizeDependencyValue(previewContextValues[key])
|
||||
const resolvedValue = resolveDependencyValue(
|
||||
key,
|
||||
previewContextValues,
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
map[key] = normalizeDependencyValue(resolvedValue)
|
||||
}
|
||||
return map
|
||||
}
|
||||
@@ -108,32 +128,25 @@ export function useDependsOnGate(
|
||||
const blockValues = (workflowValues as any)[blockId] || {}
|
||||
const map: Record<string, unknown> = {}
|
||||
for (const key of allDependsOnFields) {
|
||||
map[key] = normalizeDependencyValue((blockValues as any)[key])
|
||||
const resolvedValue = resolveDependencyValue(
|
||||
key,
|
||||
blockValues,
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
map[key] = normalizeDependencyValue(resolvedValue)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
// For backward compatibility, also provide array of values
|
||||
const dependencyValues = useMemo(
|
||||
() => allDependsOnFields.map((key) => dependencyValuesMap[key]),
|
||||
[allDependsOnFields, dependencyValuesMap]
|
||||
) as any[]
|
||||
|
||||
const isValueSatisfied = (value: unknown): boolean => {
|
||||
if (value === null || value === undefined) return false
|
||||
if (typeof value === 'string') return value.trim().length > 0
|
||||
if (Array.isArray(value)) return value.length > 0
|
||||
return value !== ''
|
||||
}
|
||||
|
||||
const depsSatisfied = useMemo(() => {
|
||||
// Check all fields (AND logic) - all must be satisfied
|
||||
const allSatisfied =
|
||||
allFields.length === 0 || allFields.every((key) => isValueSatisfied(dependencyValuesMap[key]))
|
||||
allFields.length === 0 || allFields.every((key) => isNonEmptyValue(dependencyValuesMap[key]))
|
||||
|
||||
// Check any fields (OR logic) - at least one must be satisfied
|
||||
const anySatisfied =
|
||||
anyFields.length === 0 || anyFields.some((key) => isValueSatisfied(dependencyValuesMap[key]))
|
||||
anyFields.length === 0 || anyFields.some((key) => isNonEmptyValue(dependencyValuesMap[key]))
|
||||
|
||||
return allSatisfied && anySatisfied
|
||||
}, [allFields, anyFields, dependencyValuesMap])
|
||||
@@ -146,7 +159,6 @@ export function useDependsOnGate(
|
||||
|
||||
return {
|
||||
dependsOn,
|
||||
dependencyValues,
|
||||
depsSatisfied,
|
||||
blocked,
|
||||
finalDisabled,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type JSX, type MouseEvent, memo, useRef, useState } from 'react'
|
||||
import { AlertTriangle, Wand2 } from 'lucide-react'
|
||||
import { AlertTriangle, ArrowLeftRight, Wand2 } from 'lucide-react'
|
||||
import { Label, Tooltip } from '@/components/emcn/components'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { FieldDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import {
|
||||
@@ -39,7 +38,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'
|
||||
@@ -69,6 +67,11 @@ interface SubBlockProps {
|
||||
disabled?: boolean
|
||||
fieldDiffStatus?: FieldDiffStatus
|
||||
allowExpandInPreview?: boolean
|
||||
canonicalToggle?: {
|
||||
mode: 'basic' | 'advanced'
|
||||
disabled?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,7 +187,12 @@ const renderLabel = (
|
||||
onSearchCancel: () => void
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
},
|
||||
subBlockValues?: Record<string, any>
|
||||
subBlockValues?: Record<string, any>,
|
||||
canonicalToggle?: {
|
||||
mode: 'basic' | 'advanced'
|
||||
disabled?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
): JSX.Element | null => {
|
||||
if (config.type === 'switch') return null
|
||||
if (!config.title) return null
|
||||
@@ -205,42 +213,35 @@ const renderLabel = (
|
||||
} = wandState
|
||||
|
||||
const required = isFieldRequired(config, subBlockValues)
|
||||
const showCanonicalToggle = !!canonicalToggle && !isPreview
|
||||
const canonicalToggleDisabled = disabled || canonicalToggle?.disabled
|
||||
const showWand = isWandEnabled && !isPreview && !disabled
|
||||
|
||||
return (
|
||||
<Label className='flex items-center justify-between gap-[6px] pl-[2px]'>
|
||||
<div className='flex items-center gap-[6px] whitespace-nowrap'>
|
||||
{config.title}
|
||||
{required && <span className='ml-0.5'>*</span>}
|
||||
{config.type === 'code' && config.language === 'json' && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<AlertTriangle
|
||||
className={cn(
|
||||
'h-4 w-4 cursor-pointer text-destructive',
|
||||
!isValidJson ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Invalid JSON</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
<Label asChild className='flex items-center justify-between gap-[6px] pl-[2px]'>
|
||||
<div>
|
||||
<div className='flex items-center gap-[6px] whitespace-nowrap'>
|
||||
{config.title}
|
||||
{required && <span className='ml-0.5'>*</span>}
|
||||
{config.type === 'code' && config.language === 'json' && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<AlertTriangle
|
||||
className={cn(
|
||||
'h-4 w-4 cursor-pointer text-destructive',
|
||||
!isValidJson ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Invalid JSON</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wand inline prompt */}
|
||||
{isWandEnabled && !isPreview && !disabled && (
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end pr-[4px]'>
|
||||
{!isSearchActive ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[12px] w-[12px] flex-shrink-0 p-0 hover:bg-transparent'
|
||||
aria-label='Generate with AI'
|
||||
onClick={onSearchClick}
|
||||
>
|
||||
<Wand2 className='!h-[12px] !w-[12px] bg-transparent text-[var(--text-secondary)]' />
|
||||
</Button>
|
||||
) : (
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end gap-[6px] pr-[4px]'>
|
||||
{isSearchActive && showWand ? (
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type='text'
|
||||
@@ -256,14 +257,47 @@ const renderLabel = (
|
||||
}}
|
||||
disabled={isStreaming}
|
||||
className={cn(
|
||||
'h-[12px] w-full min-w-[100px] border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none',
|
||||
'h-[12px] w-full min-w-[100px] border-none bg-transparent py-0 text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none',
|
||||
isStreaming && 'text-muted-foreground'
|
||||
)}
|
||||
placeholder='Describe...'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{showWand && (
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0'
|
||||
aria-label='Generate with AI'
|
||||
onClick={onSearchClick}
|
||||
>
|
||||
<Wand2 className='!h-[12px] !w-[12px] text-[var(--text-secondary)]' />
|
||||
</button>
|
||||
)}
|
||||
{showCanonicalToggle && (
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:opacity-50'
|
||||
onClick={canonicalToggle?.onToggle}
|
||||
disabled={canonicalToggleDisabled}
|
||||
aria-label={
|
||||
canonicalToggle?.mode === 'advanced' ? 'Use selector' : 'Enter manual ID'
|
||||
}
|
||||
>
|
||||
<ArrowLeftRight
|
||||
className={cn(
|
||||
'!h-[12px] !w-[12px]',
|
||||
canonicalToggle?.mode === 'advanced'
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Label>
|
||||
)
|
||||
}
|
||||
@@ -287,7 +321,9 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
|
||||
prevProps.subBlockValues === nextProps.subBlockValues &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
|
||||
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview
|
||||
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
|
||||
prevProps.canonicalToggle?.mode === nextProps.canonicalToggle?.mode &&
|
||||
prevProps.canonicalToggle?.disabled === nextProps.canonicalToggle?.disabled
|
||||
)
|
||||
}
|
||||
|
||||
@@ -316,6 +352,7 @@ function SubBlockComponent({
|
||||
disabled = false,
|
||||
fieldDiffStatus,
|
||||
allowExpandInPreview,
|
||||
canonicalToggle,
|
||||
}: SubBlockProps): JSX.Element {
|
||||
const [isValidJson, setIsValidJson] = useState(true)
|
||||
const [isSearchActive, setIsSearchActive] = useState(false)
|
||||
@@ -867,17 +904,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
|
||||
@@ -914,7 +940,8 @@ function SubBlockComponent({
|
||||
onSearchCancel: handleSearchCancel,
|
||||
searchInputRef,
|
||||
},
|
||||
subBlockValues
|
||||
subBlockValues,
|
||||
canonicalToggle
|
||||
)}
|
||||
{renderInput()}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { BookOpen, Check, ChevronUp, Pencil, Settings } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BookOpen, Check, ChevronDown, ChevronUp, Pencil } from 'lucide-react'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
hasAdvancedValues,
|
||||
hasStandaloneAdvancedFields,
|
||||
isCanonicalPair,
|
||||
resolveCanonicalMode,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
ConnectionBlocks,
|
||||
@@ -89,11 +96,28 @@ export function Editor() {
|
||||
)
|
||||
)
|
||||
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = currentBlock?.data?.canonicalModes
|
||||
const advancedValuesPresent = hasAdvancedValues(
|
||||
blockConfig?.subBlocks || [],
|
||||
blockSubBlockValues,
|
||||
canonicalIndex
|
||||
)
|
||||
const displayAdvancedOptions = advancedMode || advancedValuesPresent
|
||||
|
||||
const hasAdvancedOnlyFields = useMemo(
|
||||
() => hasStandaloneAdvancedFields(blockConfig?.subBlocks || [], canonicalIndex),
|
||||
[blockConfig?.subBlocks, canonicalIndex]
|
||||
)
|
||||
|
||||
// Get subblock layout using custom hook
|
||||
const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout(
|
||||
blockConfig || ({} as any),
|
||||
currentBlockId || '',
|
||||
advancedMode,
|
||||
displayAdvancedOptions,
|
||||
triggerMode,
|
||||
activeWorkflowId,
|
||||
blockSubBlockValues,
|
||||
@@ -109,21 +133,23 @@ export function Editor() {
|
||||
})
|
||||
|
||||
// Collaborative actions
|
||||
const { collaborativeToggleBlockAdvancedMode, collaborativeUpdateBlockName } =
|
||||
useCollaborativeWorkflow()
|
||||
const {
|
||||
collaborativeSetBlockCanonicalMode,
|
||||
collaborativeUpdateBlockName,
|
||||
collaborativeToggleBlockAdvancedMode,
|
||||
} = useCollaborativeWorkflow()
|
||||
|
||||
// Advanced mode toggle handler
|
||||
const handleToggleAdvancedMode = useCallback(() => {
|
||||
if (!currentBlockId || !userPermissions.canEdit) return
|
||||
collaborativeToggleBlockAdvancedMode(currentBlockId)
|
||||
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
|
||||
|
||||
// Rename state
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [editedName, setEditedName] = useState('')
|
||||
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Mode toggle handlers
|
||||
const handleToggleAdvancedMode = useCallback(() => {
|
||||
if (currentBlockId && userPermissions.canEdit) {
|
||||
collaborativeToggleBlockAdvancedMode(currentBlockId)
|
||||
}
|
||||
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
|
||||
|
||||
/**
|
||||
* Handles starting the rename process.
|
||||
*/
|
||||
@@ -183,9 +209,6 @@ export function Editor() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if block has advanced mode or trigger mode available
|
||||
const hasAdvancedMode = blockConfig?.subBlocks?.some((sb) => sb.mode === 'advanced')
|
||||
|
||||
// Determine if connections are at minimum height (collapsed state)
|
||||
const isConnectionsAtMinHeight = connectionsHeight <= 35
|
||||
|
||||
@@ -278,25 +301,6 @@ export function Editor() {
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)} */}
|
||||
{/* Mode toggles - Only show for regular blocks, not subflows */}
|
||||
{currentBlock && !isSubflow && hasAdvancedMode && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='p-0'
|
||||
onClick={handleToggleAdvancedMode}
|
||||
disabled={!userPermissions.canEdit}
|
||||
aria-label='Toggle advanced mode'
|
||||
>
|
||||
<Settings className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Advanced mode</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
@@ -355,6 +359,19 @@ export function Editor() {
|
||||
subBlock,
|
||||
subBlockState
|
||||
)
|
||||
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlock.id]
|
||||
const canonicalGroup = canonicalId
|
||||
? canonicalIndex.groupsById[canonicalId]
|
||||
: undefined
|
||||
const isCanonicalSwap = isCanonicalPair(canonicalGroup)
|
||||
const canonicalMode =
|
||||
canonicalGroup && isCanonicalSwap
|
||||
? resolveCanonicalMode(
|
||||
canonicalGroup,
|
||||
blockSubBlockValues,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div key={stableKey} className='subblock-row'>
|
||||
@@ -366,6 +383,24 @@ export function Editor() {
|
||||
disabled={!userPermissions.canEdit}
|
||||
fieldDiffStatus={undefined}
|
||||
allowExpandInPreview={false}
|
||||
canonicalToggle={
|
||||
isCanonicalSwap && canonicalMode && canonicalId
|
||||
? {
|
||||
mode: canonicalMode,
|
||||
disabled: !userPermissions.canEdit,
|
||||
onToggle: () => {
|
||||
if (!currentBlockId) return
|
||||
const nextMode =
|
||||
canonicalMode === 'advanced' ? 'basic' : 'advanced'
|
||||
collaborativeSetBlockCanonicalMode(
|
||||
currentBlockId,
|
||||
canonicalId,
|
||||
nextMode
|
||||
)
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{index < subBlocks.length - 1 && (
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
@@ -383,6 +418,30 @@ export function Editor() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Mode Toggle - Only show when block has standalone advanced-only fields */}
|
||||
{hasAdvancedOnlyFields && userPermissions.canEdit && (
|
||||
<div className='flex items-center justify-center pt-[8px] pb-[4px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleToggleAdvancedMode}
|
||||
className='h-[28px] gap-[6px] px-[10px] text-[12px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
{displayAdvancedOptions ? (
|
||||
<>
|
||||
<ChevronUp className='h-[14px] w-[14px]' />
|
||||
Hide advanced fields
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className='h-[14px] w-[14px]' />
|
||||
Show advanced fields
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useMemo } from 'react'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
evaluateSubBlockCondition,
|
||||
isSubBlockFeatureEnabled,
|
||||
isSubBlockVisibleForMode,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
@@ -27,6 +32,10 @@ export function useEditorSubblockLayout(
|
||||
blockSubBlockValues: Record<string, any>,
|
||||
isSnapshotView: boolean
|
||||
) {
|
||||
const blockDataFromStore = useWorkflowStore(
|
||||
useCallback((state) => state.blocks?.[blockId]?.data, [blockId])
|
||||
)
|
||||
|
||||
return useMemo(() => {
|
||||
// Guard against missing config or block selection
|
||||
if (!config || !Array.isArray((config as any).subBlocks) || !blockId) {
|
||||
@@ -46,6 +55,7 @@ export function useEditorSubblockLayout(
|
||||
|
||||
const mergedState = mergedMap ? mergedMap[blockId] : undefined
|
||||
const mergedSubBlocks = mergedState?.subBlocks || {}
|
||||
const blockData = isSnapshotView ? mergedState?.data || {} : blockDataFromStore || {}
|
||||
|
||||
const stateToUse = Object.keys(mergedSubBlocks).reduce(
|
||||
(acc, key) => {
|
||||
@@ -69,13 +79,23 @@ export function useEditorSubblockLayout(
|
||||
}
|
||||
|
||||
// Filter visible blocks and those that meet their conditions
|
||||
const rawValues = Object.entries(stateToUse).reduce<Record<string, unknown>>(
|
||||
(acc, [key, entry]) => {
|
||||
acc[key] = entry?.value
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
const canonicalIndex = buildCanonicalIndex(config.subBlocks || [])
|
||||
const effectiveAdvanced = displayAdvancedMode
|
||||
const canonicalModeOverrides = blockData?.canonicalModes
|
||||
|
||||
const visibleSubBlocks = (config.subBlocks || []).filter((block) => {
|
||||
if (block.hidden) return false
|
||||
|
||||
// Check required feature if specified - declarative feature gating
|
||||
if (block.requiresFeature && !isTruthy(getEnv(block.requiresFeature))) {
|
||||
return false
|
||||
}
|
||||
if (!isSubBlockFeatureEnabled(block)) return false
|
||||
|
||||
// Special handling for trigger-config type (legacy trigger configuration UI)
|
||||
if (block.type === ('trigger-config' as SubBlockType)) {
|
||||
@@ -84,13 +104,8 @@ export function useEditorSubblockLayout(
|
||||
}
|
||||
|
||||
// Filter by mode if specified
|
||||
if (block.mode) {
|
||||
if (block.mode === 'basic' && displayAdvancedMode) return false
|
||||
if (block.mode === 'advanced' && !displayAdvancedMode) return false
|
||||
if (block.mode === 'trigger') {
|
||||
// Show trigger mode blocks only when in trigger mode
|
||||
if (!displayTriggerMode) return false
|
||||
}
|
||||
if (block.mode === 'trigger') {
|
||||
if (!displayTriggerMode) return false
|
||||
}
|
||||
|
||||
// When in trigger mode, hide blocks that don't have mode: 'trigger'
|
||||
@@ -98,42 +113,22 @@ export function useEditorSubblockLayout(
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!isSubBlockVisibleForMode(
|
||||
block,
|
||||
effectiveAdvanced,
|
||||
canonicalIndex,
|
||||
rawValues,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If there's no condition, the block should be shown
|
||||
if (!block.condition) return true
|
||||
|
||||
// If condition is a function, call it to get the actual condition object
|
||||
const actualCondition =
|
||||
typeof block.condition === 'function' ? block.condition() : block.condition
|
||||
|
||||
// Get the values of the fields this block depends on from the appropriate state
|
||||
const fieldValue = stateToUse[actualCondition.field]?.value
|
||||
const andFieldValue = actualCondition.and
|
||||
? stateToUse[actualCondition.and.field]?.value
|
||||
: undefined
|
||||
|
||||
// Check if the condition value is an array
|
||||
const isValueMatch = Array.isArray(actualCondition.value)
|
||||
? fieldValue != null &&
|
||||
(actualCondition.not
|
||||
? !actualCondition.value.includes(fieldValue as string | number | boolean)
|
||||
: actualCondition.value.includes(fieldValue as string | number | boolean))
|
||||
: actualCondition.not
|
||||
? fieldValue !== actualCondition.value
|
||||
: fieldValue === actualCondition.value
|
||||
|
||||
// Check both conditions if 'and' is present
|
||||
const isAndValueMatch =
|
||||
!actualCondition.and ||
|
||||
(Array.isArray(actualCondition.and.value)
|
||||
? andFieldValue != null &&
|
||||
(actualCondition.and.not
|
||||
? !actualCondition.and.value.includes(andFieldValue as string | number | boolean)
|
||||
: actualCondition.and.value.includes(andFieldValue as string | number | boolean))
|
||||
: actualCondition.and.not
|
||||
? andFieldValue !== actualCondition.and.value
|
||||
: andFieldValue === actualCondition.and.value)
|
||||
|
||||
return isValueMatch && isAndValueMatch
|
||||
return evaluateSubBlockCondition(block.condition, rawValues)
|
||||
})
|
||||
|
||||
return { subBlocks: visibleSubBlocks, stateToUse }
|
||||
@@ -147,5 +142,6 @@ export function useEditorSubblockLayout(
|
||||
blockSubBlockValues,
|
||||
activeWorkflowId,
|
||||
isSnapshotView,
|
||||
blockDataFromStore,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,11 +3,18 @@ import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
|
||||
import { Badge, Tooltip } from '@/components/emcn'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createMcpToolId } from '@/lib/mcp/utils'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
evaluateSubBlockCondition,
|
||||
hasAdvancedValues,
|
||||
isSubBlockFeatureEnabled,
|
||||
isSubBlockVisibleForMode,
|
||||
resolveDependencyValue,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
ActionBar,
|
||||
@@ -332,6 +339,9 @@ const SubBlockRow = ({
|
||||
workflowId,
|
||||
blockId,
|
||||
allSubBlockValues,
|
||||
displayAdvancedOptions,
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides,
|
||||
}: {
|
||||
title: string
|
||||
value?: string
|
||||
@@ -341,6 +351,9 @@ const SubBlockRow = ({
|
||||
workflowId?: string
|
||||
blockId?: string
|
||||
allSubBlockValues?: Record<string, { value: unknown }>
|
||||
displayAdvancedOptions?: boolean
|
||||
canonicalIndex?: ReturnType<typeof buildCanonicalIndex>
|
||||
canonicalModeOverrides?: Record<string, 'basic' | 'advanced'>
|
||||
}) => {
|
||||
const getStringValue = useCallback(
|
||||
(key?: string): string | undefined => {
|
||||
@@ -351,17 +364,43 @@ const SubBlockRow = ({
|
||||
[allSubBlockValues]
|
||||
)
|
||||
|
||||
const rawValues = useMemo(() => {
|
||||
if (!allSubBlockValues) return {}
|
||||
return Object.entries(allSubBlockValues).reduce<Record<string, unknown>>(
|
||||
(acc, [key, entry]) => {
|
||||
acc[key] = entry?.value
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
}, [allSubBlockValues])
|
||||
|
||||
const dependencyValues = useMemo(() => {
|
||||
const fields = getDependsOnFields(subBlock?.dependsOn)
|
||||
if (!fields.length) return {}
|
||||
return fields.reduce<Record<string, string>>((accumulator, dependency) => {
|
||||
const dependencyValue = getStringValue(dependency)
|
||||
if (dependencyValue) {
|
||||
accumulator[dependency] = dependencyValue
|
||||
const dependencyValue = resolveDependencyValue(
|
||||
dependency,
|
||||
rawValues,
|
||||
canonicalIndex || buildCanonicalIndex([]),
|
||||
canonicalModeOverrides
|
||||
)
|
||||
const dependencyString =
|
||||
typeof dependencyValue === 'string' && dependencyValue.length > 0
|
||||
? dependencyValue
|
||||
: undefined
|
||||
if (dependencyString) {
|
||||
accumulator[dependency] = dependencyString
|
||||
}
|
||||
return accumulator
|
||||
}, {})
|
||||
}, [getStringValue, subBlock?.dependsOn])
|
||||
}, [
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides,
|
||||
displayAdvancedOptions,
|
||||
rawValues,
|
||||
subBlock?.dependsOn,
|
||||
])
|
||||
|
||||
const credentialSourceId =
|
||||
subBlock?.type === 'oauth-input' && typeof rawValue === 'string' ? rawValue : undefined
|
||||
@@ -570,6 +609,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
|
||||
const [isDeploying, setIsDeploying] = useState(false)
|
||||
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const deployWorkflow = useCallback(
|
||||
async (workflowId: string) => {
|
||||
@@ -630,6 +670,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
[activeWorkflowId, id]
|
||||
)
|
||||
)
|
||||
const canonicalIndex = useMemo(() => buildCanonicalIndex(config.subBlocks), [config.subBlocks])
|
||||
const canonicalModeOverrides = currentStoreBlock?.data?.canonicalModes
|
||||
|
||||
const subBlockRowsData = useMemo(() => {
|
||||
const rows: SubBlockConfig[][] = []
|
||||
@@ -652,16 +694,23 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
{} as Record<string, { value: unknown }>
|
||||
)
|
||||
|
||||
const effectiveAdvanced = displayAdvancedMode
|
||||
const rawValues = Object.entries(stateToUse).reduce<Record<string, unknown>>(
|
||||
(acc, [key, entry]) => {
|
||||
acc[key] = entry?.value
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
const effectiveAdvanced = userPermissions.canEdit
|
||||
? displayAdvancedMode
|
||||
: displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex)
|
||||
const effectiveTrigger = displayTriggerMode
|
||||
|
||||
const visibleSubBlocks = config.subBlocks.filter((block) => {
|
||||
if (block.hidden) return false
|
||||
if (block.hideFromPreview) return false
|
||||
|
||||
if (block.requiresFeature && !isTruthy(getEnv(block.requiresFeature))) {
|
||||
return false
|
||||
}
|
||||
if (!isSubBlockFeatureEnabled(block)) return false
|
||||
|
||||
const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers'
|
||||
|
||||
@@ -679,40 +728,21 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
}
|
||||
}
|
||||
|
||||
if (block.mode === 'basic' && effectiveAdvanced) return false
|
||||
if (block.mode === 'advanced' && !effectiveAdvanced) return false
|
||||
if (
|
||||
!isSubBlockVisibleForMode(
|
||||
block,
|
||||
effectiveAdvanced,
|
||||
canonicalIndex,
|
||||
rawValues,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!block.condition) return true
|
||||
|
||||
const actualCondition =
|
||||
typeof block.condition === 'function' ? block.condition() : block.condition
|
||||
|
||||
const fieldValue = stateToUse[actualCondition.field]?.value
|
||||
const andFieldValue = actualCondition.and
|
||||
? stateToUse[actualCondition.and.field]?.value
|
||||
: undefined
|
||||
|
||||
const isValueMatch = Array.isArray(actualCondition.value)
|
||||
? fieldValue != null &&
|
||||
(actualCondition.not
|
||||
? !actualCondition.value.includes(fieldValue as string | number | boolean)
|
||||
: actualCondition.value.includes(fieldValue as string | number | boolean))
|
||||
: actualCondition.not
|
||||
? fieldValue !== actualCondition.value
|
||||
: fieldValue === actualCondition.value
|
||||
|
||||
const isAndValueMatch =
|
||||
!actualCondition.and ||
|
||||
(Array.isArray(actualCondition.and.value)
|
||||
? andFieldValue != null &&
|
||||
(actualCondition.and.not
|
||||
? !actualCondition.and.value.includes(andFieldValue as string | number | boolean)
|
||||
: actualCondition.and.value.includes(andFieldValue as string | number | boolean))
|
||||
: actualCondition.and.not
|
||||
? andFieldValue !== actualCondition.and.value
|
||||
: andFieldValue === actualCondition.and.value)
|
||||
|
||||
return isValueMatch && isAndValueMatch
|
||||
return evaluateSubBlockCondition(block.condition, rawValues)
|
||||
})
|
||||
|
||||
visibleSubBlocks.forEach((block) => {
|
||||
@@ -744,12 +774,33 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
data.subBlockValues,
|
||||
currentWorkflow.isDiffMode,
|
||||
currentBlock,
|
||||
canonicalModeOverrides,
|
||||
userPermissions.canEdit,
|
||||
canonicalIndex,
|
||||
blockSubBlockValues,
|
||||
activeWorkflowId,
|
||||
])
|
||||
|
||||
const subBlockRows = subBlockRowsData.rows
|
||||
const subBlockState = subBlockRowsData.stateToUse
|
||||
const effectiveAdvanced = useMemo(() => {
|
||||
const rawValues = Object.entries(subBlockState).reduce<Record<string, unknown>>(
|
||||
(acc, [key, entry]) => {
|
||||
acc[key] = entry?.value
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
return userPermissions.canEdit
|
||||
? displayAdvancedMode
|
||||
: displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex)
|
||||
}, [
|
||||
subBlockState,
|
||||
displayAdvancedMode,
|
||||
config.subBlocks,
|
||||
canonicalIndex,
|
||||
userPermissions.canEdit,
|
||||
])
|
||||
|
||||
/**
|
||||
* Determine if block has content below the header (subblocks or error row).
|
||||
@@ -912,7 +963,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
const showWebhookIndicator = (isStarterBlock || isWebhookTriggerBlock) && isWebhookConfigured
|
||||
const shouldShowScheduleBadge =
|
||||
type === 'schedule' && !isLoadingScheduleInfo && scheduleInfo !== null
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
|
||||
|
||||
return (
|
||||
@@ -921,7 +971,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 && (
|
||||
@@ -957,12 +1007,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
|
||||
@@ -1129,6 +1176,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
workflowId={currentWorkflowId}
|
||||
blockId={id}
|
||||
allSubBlockValues={subBlockState}
|
||||
displayAdvancedOptions={effectiveAdvanced}
|
||||
canonicalIndex={canonicalIndex}
|
||||
canonicalModeOverrides={canonicalModeOverrides}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { WorkflowControls } from './workflow-controls'
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -14,6 +14,13 @@ import { ReactFlowProvider } from 'reactflow'
|
||||
import { Badge, Button, ChevronDown, Code, Combobox, Input, Label } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
evaluateSubBlockCondition,
|
||||
hasAdvancedValues,
|
||||
isSubBlockFeatureEnabled,
|
||||
isSubBlockVisibleForMode,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { SnapshotContextMenu } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components'
|
||||
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
@@ -24,56 +31,6 @@ import { navigatePath } from '@/executor/variables/resolvers/reference'
|
||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Evaluate whether a subblock's condition is met based on current values.
|
||||
*/
|
||||
function evaluateCondition(
|
||||
condition: SubBlockConfig['condition'],
|
||||
subBlockValues: Record<string, { value: unknown } | unknown>
|
||||
): boolean {
|
||||
if (!condition) return true
|
||||
|
||||
const actualCondition = typeof condition === 'function' ? condition() : condition
|
||||
|
||||
const fieldValueObj = subBlockValues[actualCondition.field]
|
||||
const fieldValue =
|
||||
fieldValueObj && typeof fieldValueObj === 'object' && 'value' in fieldValueObj
|
||||
? (fieldValueObj as { value: unknown }).value
|
||||
: fieldValueObj
|
||||
|
||||
const conditionValues = Array.isArray(actualCondition.value)
|
||||
? actualCondition.value
|
||||
: [actualCondition.value]
|
||||
|
||||
let isMatch = conditionValues.some((v) => v === fieldValue)
|
||||
|
||||
if (actualCondition.not) {
|
||||
isMatch = !isMatch
|
||||
}
|
||||
|
||||
if (actualCondition.and && isMatch) {
|
||||
const andFieldValueObj = subBlockValues[actualCondition.and.field]
|
||||
const andFieldValue =
|
||||
andFieldValueObj && typeof andFieldValueObj === 'object' && 'value' in andFieldValueObj
|
||||
? (andFieldValueObj as { value: unknown }).value
|
||||
: andFieldValueObj
|
||||
|
||||
const andConditionValues = Array.isArray(actualCondition.and.value)
|
||||
? actualCondition.and.value
|
||||
: [actualCondition.and.value]
|
||||
|
||||
let andMatch = andConditionValues.some((v) => v === andFieldValue)
|
||||
|
||||
if (actualCondition.and.not) {
|
||||
andMatch = !andMatch
|
||||
}
|
||||
|
||||
isMatch = isMatch && andMatch
|
||||
}
|
||||
|
||||
return isMatch
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for display as JSON string
|
||||
*/
|
||||
@@ -1122,13 +1079,44 @@ function BlockDetailsSidebarContent({
|
||||
)
|
||||
}
|
||||
|
||||
const rawValues = useMemo(() => {
|
||||
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
|
||||
if (entry && typeof entry === 'object' && 'value' in entry) {
|
||||
acc[key] = (entry as { value: unknown }).value
|
||||
} else {
|
||||
acc[key] = entry
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}, [subBlockValues])
|
||||
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig.subBlocks),
|
||||
[blockConfig.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = block.data?.canonicalModes
|
||||
const effectiveAdvanced =
|
||||
(block.advancedMode ?? false) ||
|
||||
hasAdvancedValues(blockConfig.subBlocks, rawValues, canonicalIndex)
|
||||
|
||||
const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => {
|
||||
if (subBlock.hidden || subBlock.hideFromPreview) return false
|
||||
if (subBlock.mode === 'trigger') return false
|
||||
if (subBlock.condition) {
|
||||
return evaluateCondition(subBlock.condition, subBlockValues)
|
||||
// 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 (!isSubBlockFeatureEnabled(subBlock)) return false
|
||||
if (
|
||||
!isSubBlockVisibleForMode(
|
||||
subBlock,
|
||||
effectiveAdvanced,
|
||||
canonicalIndex,
|
||||
rawValues,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return evaluateSubBlockCondition(subBlock.condition, rawValues)
|
||||
})
|
||||
|
||||
const statusVariant =
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user