mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-05 02:59:27 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ade4ceace6 | ||
|
|
4bfa3657df | ||
|
|
ff5e4721c7 | ||
|
|
aca2ddbce4 | ||
|
|
db938fc939 | ||
|
|
f98a1240a8 | ||
|
|
1671eca21f |
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -1,3 +1,25 @@
|
||||||
|
## [2.25.3](https://github.com/n8n-io/n8n/compare/n8n@2.25.2...n8n@2.25.3) (2026-06-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Prevent evaluation executions from stalling in status new ([#31701](https://github.com/n8n-io/n8n/issues/31701)) ([4bfa365](https://github.com/n8n-io/n8n/commit/4bfa3657df5fb7c004d6832597233e642ff0c97d))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Implements AI Assistant empty state workflow previews experiment ([#31663](https://github.com/n8n-io/n8n/issues/31663)) ([ff5e472](https://github.com/n8n-io/n8n/commit/ff5e4721c73dc0ae00751ccbb4da43670e66e874))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.25.2](https://github.com/n8n-io/n8n/compare/n8n@2.25.1...n8n@2.25.2) (2026-06-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** External agent channels correctly utilise the user ID for episodic memory ([#31589](https://github.com/n8n-io/n8n/issues/31589)) ([db938fc](https://github.com/n8n-io/n8n/commit/db938fc9390f68f9b477b34a8bb01b1243a19456))
|
||||||
|
* **core:** The n8n import:workflow --activeState=fromJson cli can fail for subworkflow dependencies ([#31569](https://github.com/n8n-io/n8n/issues/31569)) ([1671eca](https://github.com/n8n-io/n8n/commit/1671eca21fc6ca3ca03dc908b16021eb3be3a7c0))
|
||||||
|
|
||||||
|
|
||||||
# [2.25.0](https://github.com/n8n-io/n8n/compare/n8n@2.24.0...n8n@2.25.0) (2026-06-02)
|
# [2.25.0](https://github.com/n8n-io/n8n/compare/n8n@2.24.0...n8n@2.25.0) (2026-06-02)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-monorepo",
|
"name": "n8n-monorepo",
|
||||||
"version": "2.25.1",
|
"version": "2.25.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.22",
|
"node": ">=22.22",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n",
|
"name": "n8n",
|
||||||
"version": "2.25.1",
|
"version": "2.25.3",
|
||||||
"description": "n8n Workflow Automation Tool",
|
"description": "n8n Workflow Automation Tool",
|
||||||
"main": "dist/index",
|
"main": "dist/index",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Logger } from '@n8n/backend-common';
|
import { Logger } from '@n8n/backend-common';
|
||||||
import { mockInstance } from '@n8n/backend-test-utils';
|
import { mockInstance, mockLogger } from '@n8n/backend-test-utils';
|
||||||
import { ExecutionsConfig } from '@n8n/config';
|
import { ExecutionsConfig } from '@n8n/config';
|
||||||
|
import type { GlobalConfig } from '@n8n/config';
|
||||||
import type { ExecutionRepository } from '@n8n/db';
|
import type { ExecutionRepository } from '@n8n/db';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { captor, mock } from 'jest-mock-extended';
|
import { captor, mock } from 'jest-mock-extended';
|
||||||
|
|
@ -22,7 +23,10 @@ import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import { ActiveExecutions } from '@/active-executions';
|
import { ActiveExecutions } from '@/active-executions';
|
||||||
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
|
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
|
||||||
|
import type { EventService } from '@/events/event.service';
|
||||||
import type { ExecutionPersistence } from '@/executions/execution-persistence';
|
import type { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||||
|
import type { License } from '@/license';
|
||||||
|
import type { Telemetry } from '@/telemetry';
|
||||||
|
|
||||||
jest.mock('n8n-workflow', () => ({
|
jest.mock('n8n-workflow', () => ({
|
||||||
...jest.requireActual('n8n-workflow'),
|
...jest.requireActual('n8n-workflow'),
|
||||||
|
|
@ -183,6 +187,91 @@ describe('ActiveExecutions', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TRUST-144: evaluation executions stayed in status 'new' (startedAt
|
||||||
|
// null) and never got picked up. The test-runner fan-out already throttles
|
||||||
|
// the shared evaluation concurrency queue before launching each case, so a
|
||||||
|
// second reservation here consumed a second slot from the same queue for
|
||||||
|
// the same case. Once the fan-out filled the queue up to its cap, this
|
||||||
|
// nested reservation blocked forever — before `setRunning` ran — leaving
|
||||||
|
// the execution stuck at status 'new'.
|
||||||
|
describe('evaluation executions do not double-reserve concurrency (TRUST-144)', () => {
|
||||||
|
// A real service whose evaluation queue has a single slot, already
|
||||||
|
// taken by the test-runner fan-out for this case.
|
||||||
|
const buildFullEvalConcurrencyControl = () => {
|
||||||
|
const service = new ConcurrencyControlService(
|
||||||
|
mockLogger(),
|
||||||
|
executionRepository,
|
||||||
|
mock<Telemetry>(),
|
||||||
|
mock<EventService>(),
|
||||||
|
mock<GlobalConfig>({
|
||||||
|
executions: {
|
||||||
|
mode: 'regular',
|
||||||
|
concurrency: { productionLimit: -1, evaluationLimit: 1 },
|
||||||
|
},
|
||||||
|
deployment: { type: 'default' },
|
||||||
|
}),
|
||||||
|
mock<License>(),
|
||||||
|
);
|
||||||
|
return service;
|
||||||
|
};
|
||||||
|
|
||||||
|
const evalExecutionData: IWorkflowExecutionDataProcess = {
|
||||||
|
...executionData,
|
||||||
|
executionMode: 'evaluation',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('reaches setRunning even when the evaluation queue is full', async () => {
|
||||||
|
const realConcurrencyControl = buildFullEvalConcurrencyControl();
|
||||||
|
|
||||||
|
// The fan-out has taken the only evaluation slot for this case.
|
||||||
|
await realConcurrencyControl.throttle({
|
||||||
|
mode: 'evaluation',
|
||||||
|
executionId: 'run-1-case-0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const evalActiveExecutions = new ActiveExecutions(
|
||||||
|
mock(),
|
||||||
|
executionRepository,
|
||||||
|
executionPersistence,
|
||||||
|
realConcurrencyControl,
|
||||||
|
mock(),
|
||||||
|
executionsConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
let resolvedId: string | undefined;
|
||||||
|
const addPromise = evalActiveExecutions.add(evalExecutionData).then((id) => {
|
||||||
|
resolvedId = id;
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If `add` re-reserved a slot it would block here (queue is full).
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(resolvedId).toBe(FAKE_EXECUTION_ID);
|
||||||
|
expect(executionRepository.setRunning).toHaveBeenCalledWith(FAKE_EXECUTION_ID);
|
||||||
|
|
||||||
|
await addPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not throttle the evaluation queue', async () => {
|
||||||
|
const realConcurrencyControl = buildFullEvalConcurrencyControl();
|
||||||
|
const throttleSpy = jest.spyOn(realConcurrencyControl, 'throttle');
|
||||||
|
|
||||||
|
const evalActiveExecutions = new ActiveExecutions(
|
||||||
|
mock(),
|
||||||
|
executionRepository,
|
||||||
|
executionPersistence,
|
||||||
|
realConcurrencyControl,
|
||||||
|
mock(),
|
||||||
|
executionsConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
await evalActiveExecutions.add(evalExecutionData);
|
||||||
|
|
||||||
|
expect(throttleSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('attachWorkflowExecution', () => {
|
describe('attachWorkflowExecution', () => {
|
||||||
test('Should fail attaching execution to invalid executionId', async () => {
|
test('Should fail attaching execution to invalid executionId', async () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,18 @@ export class ActiveExecutions {
|
||||||
const mode = executionData.executionMode;
|
const mode = executionData.executionMode;
|
||||||
const capacityReservation = new ConcurrencyCapacityReservation(this.concurrencyControl);
|
const capacityReservation = new ConcurrencyCapacityReservation(this.concurrencyControl);
|
||||||
|
|
||||||
|
// Evaluation executions are already gated instance-wide by the
|
||||||
|
// test-runner fan-out, which throttles the shared evaluation
|
||||||
|
// concurrency queue before launching each case (see
|
||||||
|
// `test-runner.service.ee.ts`). Reserving capacity again here would
|
||||||
|
// consume a second slot from the same queue for the same case; once
|
||||||
|
// the fan-out fills the queue up to its cap, this nested reservation
|
||||||
|
// blocks forever — before `setRunning` runs — leaving the execution
|
||||||
|
// stuck at status 'new' with `startedAt` null (TRUST-144). Skip the
|
||||||
|
// reservation for evaluation mode; `release()` below is a no-op when
|
||||||
|
// nothing was reserved.
|
||||||
|
const shouldReserveCapacity = mode !== 'evaluation';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (maybeExecutionId === undefined) {
|
if (maybeExecutionId === undefined) {
|
||||||
const fullExecutionData: CreateExecutionPayload = {
|
const fullExecutionData: CreateExecutionPayload = {
|
||||||
|
|
@ -89,7 +101,9 @@ export class ActiveExecutions {
|
||||||
maybeExecutionId = await this.executionPersistence.create(fullExecutionData);
|
maybeExecutionId = await this.executionPersistence.create(fullExecutionData);
|
||||||
assert(maybeExecutionId);
|
assert(maybeExecutionId);
|
||||||
|
|
||||||
|
if (shouldReserveCapacity) {
|
||||||
await capacityReservation.reserve({ mode, executionId: maybeExecutionId });
|
await capacityReservation.reserve({ mode, executionId: maybeExecutionId });
|
||||||
|
}
|
||||||
|
|
||||||
if (this.executionsConfig.mode === 'regular') {
|
if (this.executionsConfig.mode === 'regular') {
|
||||||
await this.executionRepository.setRunning(maybeExecutionId);
|
await this.executionRepository.setRunning(maybeExecutionId);
|
||||||
|
|
@ -98,7 +112,9 @@ export class ActiveExecutions {
|
||||||
} else {
|
} else {
|
||||||
// Is an existing execution we want to finish so update in DB
|
// Is an existing execution we want to finish so update in DB
|
||||||
|
|
||||||
|
if (shouldReserveCapacity) {
|
||||||
await capacityReservation.reserve({ mode, executionId: maybeExecutionId });
|
await capacityReservation.reserve({ mode, executionId: maybeExecutionId });
|
||||||
|
}
|
||||||
|
|
||||||
const execution: Pick<IExecutionDb, 'id' | 'data' | 'waitTill' | 'status'> = {
|
const execution: Pick<IExecutionDb, 'id' | 'data' | 'waitTill' | 'status'> = {
|
||||||
id: maybeExecutionId,
|
id: maybeExecutionId,
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,12 @@ export class ImportWorkflowsCommand extends BaseCommand<z.infer<typeof flagsSche
|
||||||
|
|
||||||
const project = await this.getProject(flags.userId, flags.projectId);
|
const project = await this.getProject(flags.userId, flags.projectId);
|
||||||
|
|
||||||
|
const ownerUser = await Container.get(UserRepository).findOneByOrFail({
|
||||||
|
role: { slug: GLOBAL_OWNER_ROLE.slug },
|
||||||
|
});
|
||||||
|
// This userId will be used as the actor for publish/unpublish workflow actions
|
||||||
|
const userId = flags.userId ?? ownerUser.id;
|
||||||
|
|
||||||
const workflows = await this.readWorkflows(flags.input, flags.separate);
|
const workflows = await this.readWorkflows(flags.input, flags.separate);
|
||||||
|
|
||||||
const result = await this.checkRelations(workflows, flags.projectId, flags.userId);
|
const result = await this.checkRelations(workflows, flags.projectId, flags.userId);
|
||||||
|
|
@ -141,7 +147,7 @@ export class ImportWorkflowsCommand extends BaseCommand<z.infer<typeof flagsSche
|
||||||
|
|
||||||
this.logger.info(`Importing ${workflows.length} workflows...`);
|
this.logger.info(`Importing ${workflows.length} workflows...`);
|
||||||
|
|
||||||
await Container.get(ImportService).importWorkflows(workflows, project.id, {
|
await Container.get(ImportService).importWorkflows(workflows, project.id, userId, {
|
||||||
activeState: flags.activeState,
|
activeState: flags.activeState,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ type ChatBotLike = ConstructorParameters<typeof AgentChatBridge>[0];
|
||||||
interface FakeThread {
|
interface FakeThread {
|
||||||
id: string;
|
id: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
|
adapter?: { botUserId?: string };
|
||||||
subscribe: jest.Mock;
|
subscribe: jest.Mock;
|
||||||
post: jest.Mock;
|
post: jest.Mock;
|
||||||
startTyping: jest.Mock;
|
startTyping: jest.Mock;
|
||||||
|
|
@ -44,10 +45,11 @@ function makeBot() {
|
||||||
return { bot, handlers };
|
return { bot, handlers };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeThread(): FakeThread {
|
function makeThread(id = 'thread-1', adapter?: FakeThread['adapter']): FakeThread {
|
||||||
return {
|
return {
|
||||||
id: 'thread-1',
|
id,
|
||||||
channelId: 'channel-1',
|
channelId: 'channel-1',
|
||||||
|
adapter,
|
||||||
subscribe: jest.fn().mockResolvedValue(undefined),
|
subscribe: jest.fn().mockResolvedValue(undefined),
|
||||||
post: jest.fn().mockResolvedValue(undefined),
|
post: jest.fn().mockResolvedValue(undefined),
|
||||||
startTyping: jest.fn().mockResolvedValue(undefined),
|
startTyping: jest.fn().mockResolvedValue(undefined),
|
||||||
|
|
@ -97,6 +99,24 @@ class StreamingTestIntegration extends AgentChatIntegration {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FormattedBufferedTestIntegration extends AgentChatIntegration {
|
||||||
|
readonly type = 'test-formatted-buffered';
|
||||||
|
readonly credentialTypes: string[] = [];
|
||||||
|
readonly supportedComponents: string[] = [];
|
||||||
|
readonly description = '';
|
||||||
|
readonly displayLabel = 'Test Formatted Buffered';
|
||||||
|
readonly displayIcon = 'circle';
|
||||||
|
readonly disableStreaming = true;
|
||||||
|
readonly formatThreadId = {
|
||||||
|
fromSdk: (thread: { id: string; adapter?: { botUserId?: string } }) =>
|
||||||
|
`chat:${thread.adapter?.botUserId ?? 'bot'}-${thread.id}`,
|
||||||
|
toSdk: (threadId: string) => threadId.split('-').slice(1).join('-'),
|
||||||
|
};
|
||||||
|
async createAdapter(_ctx: AgentChatIntegrationContext): Promise<unknown> {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: use real Telegram integration for testing
|
// TODO: use real Telegram integration for testing
|
||||||
|
|
||||||
describe('AgentChatBridge — consumeStream', () => {
|
describe('AgentChatBridge — consumeStream', () => {
|
||||||
|
|
@ -117,6 +137,7 @@ describe('AgentChatBridge — consumeStream', () => {
|
||||||
registry = new ChatIntegrationRegistry();
|
registry = new ChatIntegrationRegistry();
|
||||||
registry.register(new BufferingTestIntegration());
|
registry.register(new BufferingTestIntegration());
|
||||||
registry.register(new StreamingTestIntegration());
|
registry.register(new StreamingTestIntegration());
|
||||||
|
registry.register(new FormattedBufferedTestIntegration());
|
||||||
Container.set(ChatIntegrationRegistry, registry);
|
Container.set(ChatIntegrationRegistry, registry);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -219,10 +240,11 @@ describe('AgentChatBridge — consumeStream', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when integration keeps streaming enabled', () => {
|
describe('when deriving memory scope', () => {
|
||||||
it('uses the formatted chat thread as the episodic memory partition', async () => {
|
it('uses the platform user as the episodic memory partition across threads', async () => {
|
||||||
const { bot, handlers } = makeBot();
|
const { bot, handlers } = makeBot();
|
||||||
const thread = makeThread();
|
const thread1 = makeThread('thread-1');
|
||||||
|
const thread2 = makeThread('thread-2');
|
||||||
const agentExecutor = makeAgentExecutor([{ type: 'finish', finishReason: 'stop' }]);
|
const agentExecutor = makeAgentExecutor([{ type: 'finish', finishReason: 'stop' }]);
|
||||||
|
|
||||||
new AgentChatBridge(
|
new AgentChatBridge(
|
||||||
|
|
@ -235,18 +257,79 @@ describe('AgentChatBridge — consumeStream', () => {
|
||||||
streamingIntegration,
|
streamingIntegration,
|
||||||
);
|
);
|
||||||
|
|
||||||
await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } });
|
await handlers.mention!(thread1, { text: 'hi', author: { userId: 'u1', userName: 'user1' } });
|
||||||
|
await handlers.mention!(thread2, {
|
||||||
|
text: 'what did we discuss?',
|
||||||
|
author: { userId: 'u1', userName: 'user1' },
|
||||||
|
});
|
||||||
|
|
||||||
expect(agentExecutor.executeForChatPublished).toHaveBeenCalledWith(
|
expect(agentExecutor.executeForChatPublished).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
memory: expect.objectContaining({
|
memory: expect.objectContaining({
|
||||||
threadId: expect.objectContaining({ id: 'agent-1:thread-1' }),
|
threadId: expect.objectContaining({ id: 'agent-1:thread-1' }),
|
||||||
resourceId: 'integration:test-streaming:thread-1',
|
resourceId: 'integration:test-streaming:u1',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(agentExecutor.executeForChatPublished).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
memory: expect.objectContaining({
|
||||||
|
threadId: expect.objectContaining({ id: 'agent-1:thread-2' }),
|
||||||
|
resourceId: 'integration:test-streaming:u1',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a formatted thread ID separate from the platform user memory partition', async () => {
|
||||||
|
const { bot, handlers } = makeBot();
|
||||||
|
const thread1 = makeThread('1001', { botUserId: 'bot-1' });
|
||||||
|
const thread2 = makeThread('1002', { botUserId: 'bot-1' });
|
||||||
|
const agentExecutor = makeAgentExecutor([{ type: 'finish', finishReason: 'stop' }]);
|
||||||
|
|
||||||
|
new AgentChatBridge(
|
||||||
|
bot as unknown as ChatBotLike,
|
||||||
|
'agent-1',
|
||||||
|
agentExecutor as never,
|
||||||
|
componentMapper,
|
||||||
|
logger,
|
||||||
|
'project-1',
|
||||||
|
{
|
||||||
|
type: 'test-formatted-buffered',
|
||||||
|
credentialId: 'cred-1',
|
||||||
|
} as unknown as AgentIntegrationConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
await handlers.mention!(thread1, { text: 'hi', author: { userId: 'u1', userName: 'user1' } });
|
||||||
|
await handlers.mention!(thread2, {
|
||||||
|
text: 'what did we discuss?',
|
||||||
|
author: { userId: 'u1', userName: 'user1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agentExecutor.executeForChatPublished).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({
|
||||||
|
memory: expect.objectContaining({
|
||||||
|
threadId: expect.objectContaining({ id: 'agent-1:chat:bot-1-1001' }),
|
||||||
|
resourceId: 'integration:test-formatted-buffered:u1',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(agentExecutor.executeForChatPublished).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
memory: expect.objectContaining({
|
||||||
|
threadId: expect.objectContaining({ id: 'agent-1:chat:bot-1-1002' }),
|
||||||
|
resourceId: 'integration:test-formatted-buffered:u1',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when integration keeps streaming enabled', () => {
|
||||||
it('posts an AsyncIterable whose drained content equals the concatenated deltas', async () => {
|
it('posts an AsyncIterable whose drained content equals the concatenated deltas', async () => {
|
||||||
const { bot, handlers } = makeBot();
|
const { bot, handlers } = makeBot();
|
||||||
const thread = makeThread();
|
const thread = makeThread();
|
||||||
|
|
|
||||||
|
|
@ -314,7 +314,8 @@ export class AgentChatBridge {
|
||||||
subject,
|
subject,
|
||||||
});
|
});
|
||||||
// threadId.id is agent-prefixed for observation storage; resourceId keeps
|
// threadId.id is agent-prefixed for observation storage; resourceId keeps
|
||||||
// the platform identity so episodic recall remains agent + resource scoped.
|
// the platform user identity so episodic recall works across threads for
|
||||||
|
// the same user while staying isolated between users.
|
||||||
// Always run the published snapshot — integrations are production traffic.
|
// Always run the published snapshot — integrations are production traffic.
|
||||||
const stream = this.agentService.executeForChatPublished({
|
const stream = this.agentService.executeForChatPublished({
|
||||||
agentId: this.agentId,
|
agentId: this.agentId,
|
||||||
|
|
@ -322,7 +323,7 @@ export class AgentChatBridge {
|
||||||
message: text,
|
message: text,
|
||||||
memory: {
|
memory: {
|
||||||
threadId,
|
threadId,
|
||||||
resourceId: integrationMemoryResourceId(this.integration.type, platformThreadId),
|
resourceId: integrationMemoryResourceId(this.integration.type, message.author.userId),
|
||||||
},
|
},
|
||||||
integrationType: this.integration.type,
|
integrationType: this.integration.type,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,11 @@ export function draftChatMemoryResourceId(userId: string): string {
|
||||||
return `draft-chat:${userId}`;
|
return `draft-chat:${userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function integrationMemoryResourceId(integrationType: string, threadId: string): string {
|
export function integrationMemoryResourceId(
|
||||||
return `integration:${integrationType}:${threadId}`;
|
integrationType: string,
|
||||||
|
platformUserId: string,
|
||||||
|
): string {
|
||||||
|
return `integration:${integrationType}:${platformUserId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function taskRunMemoryResourceId(taskId: string): string {
|
export function taskRunMemoryResourceId(taskId: string): string {
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ describe('OtelLifecycleHandler', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
config = makeOtelConfig({ publishedOnly: false });
|
config = makeOtelConfig({ productionExecutionsOnly: false });
|
||||||
handler = new OtelLifecycleHandler(
|
handler = new OtelLifecycleHandler(
|
||||||
tracer,
|
tracer,
|
||||||
traceContextService,
|
traceContextService,
|
||||||
|
|
@ -321,7 +321,7 @@ describe('OtelLifecycleHandler', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
config = makeOtelConfig({ publishedOnly: false });
|
config = makeOtelConfig({ productionExecutionsOnly: false });
|
||||||
handler = new OtelLifecycleHandler(
|
handler = new OtelLifecycleHandler(
|
||||||
tracer,
|
tracer,
|
||||||
traceContextService,
|
traceContextService,
|
||||||
|
|
@ -437,7 +437,7 @@ describe('OtelLifecycleHandler', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
config = makeOtelConfig({ publishedOnly: false });
|
config = makeOtelConfig({ productionExecutionsOnly: false });
|
||||||
handler = new OtelLifecycleHandler(
|
handler = new OtelLifecycleHandler(
|
||||||
tracer,
|
tracer,
|
||||||
traceContextService,
|
traceContextService,
|
||||||
|
|
@ -542,7 +542,7 @@ describe('OtelLifecycleHandler', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
config = makeOtelConfig({ publishedOnly: false, includeNodeSpans: true });
|
config = makeOtelConfig({ productionExecutionsOnly: false, includeNodeSpans: true });
|
||||||
handler = new OtelLifecycleHandler(
|
handler = new OtelLifecycleHandler(
|
||||||
tracer,
|
tracer,
|
||||||
traceContextService,
|
traceContextService,
|
||||||
|
|
@ -636,7 +636,7 @@ describe('OtelLifecycleHandler', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('publishedOnly filter', () => {
|
describe('productionExecutionsOnly filter', () => {
|
||||||
const tracer = mock<ExecutionLevelTracer>();
|
const tracer = mock<ExecutionLevelTracer>();
|
||||||
const traceContextService = mock<TraceContextService>();
|
const traceContextService = mock<TraceContextService>();
|
||||||
let config = makeOtelConfig();
|
let config = makeOtelConfig();
|
||||||
|
|
@ -705,7 +705,7 @@ describe('publishedOnly filter', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
config = makeOtelConfig({ publishedOnly: true, includeNodeSpans: true });
|
config = makeOtelConfig({ productionExecutionsOnly: true, includeNodeSpans: true });
|
||||||
ownershipService.getWorkflowProjectCached.mockResolvedValue({ id: 'proj-1' } as never);
|
ownershipService.getWorkflowProjectCached.mockResolvedValue({ id: 'proj-1' } as never);
|
||||||
traceContextService.get.mockResolvedValue(undefined);
|
traceContextService.get.mockResolvedValue(undefined);
|
||||||
handler = new OtelLifecycleHandler(
|
handler = new OtelLifecycleHandler(
|
||||||
|
|
@ -717,7 +717,7 @@ describe('publishedOnly filter', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip all tracing for an inactive workflow when publishedOnly is true', async () => {
|
it('should skip all tracing for an inactive workflow when productionExecutionsOnly is true', async () => {
|
||||||
await handler.onWorkflowStart(makeWorkflowStartCtx(inactiveWorkflow));
|
await handler.onWorkflowStart(makeWorkflowStartCtx(inactiveWorkflow));
|
||||||
handler.onWorkflowEnd(makeWorkflowEndCtx(inactiveWorkflow));
|
handler.onWorkflowEnd(makeWorkflowEndCtx(inactiveWorkflow));
|
||||||
handler.onNodeStart(makeNodeStartCtx(inactiveWorkflow));
|
handler.onNodeStart(makeNodeStartCtx(inactiveWorkflow));
|
||||||
|
|
@ -730,7 +730,7 @@ describe('publishedOnly filter', () => {
|
||||||
expect(tracer.endNode).not.toHaveBeenCalled();
|
expect(tracer.endNode).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should trace an active workflow when publishedOnly is true', async () => {
|
it('should trace an active workflow when productionExecutionsOnly is true', async () => {
|
||||||
tracer.startWorkflow.mockReturnValue({ traceparent: '00-abc-def-01' });
|
tracer.startWorkflow.mockReturnValue({ traceparent: '00-abc-def-01' });
|
||||||
|
|
||||||
await handler.onWorkflowStart(makeWorkflowStartCtx(activeWorkflow));
|
await handler.onWorkflowStart(makeWorkflowStartCtx(activeWorkflow));
|
||||||
|
|
@ -739,8 +739,8 @@ describe('publishedOnly filter', () => {
|
||||||
expect(traceContextService.persist).toHaveBeenCalled();
|
expect(traceContextService.persist).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should trace an inactive workflow when publishedOnly is false', async () => {
|
it('should trace an inactive workflow when productionExecutionsOnly is false', async () => {
|
||||||
config.publishedOnly = false;
|
config.productionExecutionsOnly = false;
|
||||||
tracer.startWorkflow.mockReturnValue({ traceparent: '00-abc-def-01' });
|
tracer.startWorkflow.mockReturnValue({ traceparent: '00-abc-def-01' });
|
||||||
|
|
||||||
await handler.onWorkflowStart(makeWorkflowStartCtx(inactiveWorkflow));
|
await handler.onWorkflowStart(makeWorkflowStartCtx(inactiveWorkflow));
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ beforeAll(async () => {
|
||||||
savedEnv = saveAndSetEnv({
|
savedEnv = saveAndSetEnv({
|
||||||
N8N_OTEL_ENABLED: 'true',
|
N8N_OTEL_ENABLED: 'true',
|
||||||
N8N_OTEL_TRACES_INCLUDE_NODE_SPANS: 'true',
|
N8N_OTEL_TRACES_INCLUDE_NODE_SPANS: 'true',
|
||||||
N8N_OTEL_TRACES_PUBLISHED_ONLY: 'false',
|
N8N_OTEL_TRACES_PRODUCTION_ONLY: 'false',
|
||||||
});
|
});
|
||||||
const env = await initOtelTestEnvironment();
|
const env = await initOtelTestEnvironment();
|
||||||
otel = env.otel;
|
otel = env.otel;
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export class OtelLifecycleHandler {
|
||||||
|
|
||||||
@OnLifecycleEvent('workflowExecuteBefore')
|
@OnLifecycleEvent('workflowExecuteBefore')
|
||||||
async onWorkflowStart(ctx: WorkflowExecuteBeforeContext): Promise<void> {
|
async onWorkflowStart(ctx: WorkflowExecuteBeforeContext): Promise<void> {
|
||||||
if (this.config.publishedOnly && !this.isPublishedWorkflow(ctx.workflow)) return;
|
if (this.config.productionExecutionsOnly && !this.isPublishedWorkflow(ctx.workflow)) return;
|
||||||
|
|
||||||
const parentExecutionId = ctx.executionData?.parentExecution?.executionId;
|
const parentExecutionId = ctx.executionData?.parentExecution?.executionId;
|
||||||
const tracingContext = parentExecutionId
|
const tracingContext = parentExecutionId
|
||||||
|
|
@ -96,7 +96,7 @@ export class OtelLifecycleHandler {
|
||||||
|
|
||||||
@OnLifecycleEvent('workflowExecuteResume')
|
@OnLifecycleEvent('workflowExecuteResume')
|
||||||
async onWorkflowResume(ctx: WorkflowExecuteResumeContext): Promise<void> {
|
async onWorkflowResume(ctx: WorkflowExecuteResumeContext): Promise<void> {
|
||||||
if (this.config.publishedOnly && !this.isPublishedWorkflow(ctx.workflow)) return;
|
if (this.config.productionExecutionsOnly && !this.isPublishedWorkflow(ctx.workflow)) return;
|
||||||
|
|
||||||
const previousWorkflowExecution = await this.traceContextService.get(ctx.executionId);
|
const previousWorkflowExecution = await this.traceContextService.get(ctx.executionId);
|
||||||
|
|
||||||
|
|
@ -132,7 +132,7 @@ export class OtelLifecycleHandler {
|
||||||
|
|
||||||
@OnLifecycleEvent('workflowExecuteAfter')
|
@OnLifecycleEvent('workflowExecuteAfter')
|
||||||
onWorkflowEnd(ctx: WorkflowExecuteAfterContext): void {
|
onWorkflowEnd(ctx: WorkflowExecuteAfterContext): void {
|
||||||
if (this.config.publishedOnly && !this.isPublishedWorkflow(ctx.workflow)) return;
|
if (this.config.productionExecutionsOnly && !this.isPublishedWorkflow(ctx.workflow)) return;
|
||||||
|
|
||||||
this.tracer.endWorkflow({
|
this.tracer.endWorkflow({
|
||||||
executionId: ctx.executionId,
|
executionId: ctx.executionId,
|
||||||
|
|
@ -146,7 +146,7 @@ export class OtelLifecycleHandler {
|
||||||
|
|
||||||
@OnLifecycleEvent('nodeExecuteBefore')
|
@OnLifecycleEvent('nodeExecuteBefore')
|
||||||
onNodeStart(ctx: NodeExecuteBeforeContext): void {
|
onNodeStart(ctx: NodeExecuteBeforeContext): void {
|
||||||
if (this.config.publishedOnly && !this.isPublishedWorkflow(ctx.workflow)) return;
|
if (this.config.productionExecutionsOnly && !this.isPublishedWorkflow(ctx.workflow)) return;
|
||||||
if (!this.config.includeNodeSpans) return;
|
if (!this.config.includeNodeSpans) return;
|
||||||
|
|
||||||
const node = ctx.workflow.nodes.find((n) => n.name === ctx.nodeName);
|
const node = ctx.workflow.nodes.find((n) => n.name === ctx.nodeName);
|
||||||
|
|
@ -160,7 +160,7 @@ export class OtelLifecycleHandler {
|
||||||
|
|
||||||
@OnLifecycleEvent('nodeExecuteAfter')
|
@OnLifecycleEvent('nodeExecuteAfter')
|
||||||
onNodeEnd(ctx: NodeExecuteAfterContext): void {
|
onNodeEnd(ctx: NodeExecuteAfterContext): void {
|
||||||
if (this.config.publishedOnly && !this.isPublishedWorkflow(ctx.workflow)) return;
|
if (this.config.productionExecutionsOnly && !this.isPublishedWorkflow(ctx.workflow)) return;
|
||||||
if (!this.config.includeNodeSpans) return;
|
if (!this.config.includeNodeSpans) return;
|
||||||
|
|
||||||
const node = ctx.workflow.nodes.find((n) => n.name === ctx.nodeName);
|
const node = ctx.workflow.nodes.find((n) => n.name === ctx.nodeName);
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export class OtelConfig {
|
||||||
@Env('N8N_OTEL_TRACES_INJECT_OUTBOUND')
|
@Env('N8N_OTEL_TRACES_INJECT_OUTBOUND')
|
||||||
injectOutbound: boolean = true;
|
injectOutbound: boolean = true;
|
||||||
|
|
||||||
/** When true, only traces executions of published (active) workflows. */
|
/** When true, only traces production executions of published (active) workflows, not manual/test runs. */
|
||||||
@Env('N8N_OTEL_TRACES_PUBLISHED_ONLY')
|
@Env('N8N_OTEL_TRACES_PRODUCTION_ONLY')
|
||||||
publishedOnly: boolean = true;
|
productionExecutionsOnly: boolean = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
import { safeJoinPath, type Logger } from '@n8n/backend-common';
|
import { safeJoinPath, type Logger } from '@n8n/backend-common';
|
||||||
import type {
|
import type { CredentialsRepository, TagRepository, UserRepository } from '@n8n/db';
|
||||||
CredentialsRepository,
|
|
||||||
TagRepository,
|
|
||||||
WorkflowPublishHistoryRepository,
|
|
||||||
WorkflowRepository,
|
|
||||||
} from '@n8n/db';
|
|
||||||
import { type DataSource, type EntityManager } from '@n8n/typeorm';
|
import { type DataSource, type EntityManager } from '@n8n/typeorm';
|
||||||
import { readdir, readFile } from 'fs/promises';
|
import { readdir, readFile } from 'fs/promises';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import type { Cipher } from 'n8n-core';
|
import type { Cipher } from 'n8n-core';
|
||||||
|
|
||||||
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
|
|
||||||
import type { DataTableDDLService } from '@/modules/data-table/data-table-ddl.service';
|
import type { DataTableDDLService } from '@/modules/data-table/data-table-ddl.service';
|
||||||
import type { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
|
import type { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
|
||||||
|
import type { WorkflowService } from '@/workflows/workflow.service';
|
||||||
|
|
||||||
import { ImportService } from '../import.service';
|
import { ImportService } from '../import.service';
|
||||||
|
|
||||||
|
|
@ -35,10 +30,6 @@ jest.mock('@n8n/db', () => ({
|
||||||
WithTimestampsAndStringId: class {},
|
WithTimestampsAndStringId: class {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@/active-workflow-manager', () => ({
|
|
||||||
ActiveWorkflowManager: mock<ActiveWorkflowManager>(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('ImportService', () => {
|
describe('ImportService', () => {
|
||||||
let importService: ImportService;
|
let importService: ImportService;
|
||||||
let mockLogger: Logger;
|
let mockLogger: Logger;
|
||||||
|
|
@ -47,11 +38,10 @@ describe('ImportService', () => {
|
||||||
let mockTagRepository: TagRepository;
|
let mockTagRepository: TagRepository;
|
||||||
let mockEntityManager: EntityManager;
|
let mockEntityManager: EntityManager;
|
||||||
let mockCipher: Cipher;
|
let mockCipher: Cipher;
|
||||||
let mockActiveWorkflowManager: ActiveWorkflowManager;
|
|
||||||
let mockWorkflowIndexService: WorkflowIndexService;
|
let mockWorkflowIndexService: WorkflowIndexService;
|
||||||
let mockDataTableDDLService: DataTableDDLService;
|
let mockDataTableDDLService: DataTableDDLService;
|
||||||
let mockWorkflowRepository: WorkflowRepository;
|
let mockUserRepository: UserRepository;
|
||||||
let mockWorkflowPublishHistoryRepository: WorkflowPublishHistoryRepository;
|
let mockWorkflowService: WorkflowService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
@ -62,11 +52,10 @@ describe('ImportService', () => {
|
||||||
mockTagRepository = mock<TagRepository>();
|
mockTagRepository = mock<TagRepository>();
|
||||||
mockEntityManager = mock<EntityManager>();
|
mockEntityManager = mock<EntityManager>();
|
||||||
mockCipher = mock<Cipher>();
|
mockCipher = mock<Cipher>();
|
||||||
mockActiveWorkflowManager = mock<ActiveWorkflowManager>();
|
|
||||||
mockWorkflowIndexService = mock<WorkflowIndexService>();
|
mockWorkflowIndexService = mock<WorkflowIndexService>();
|
||||||
mockDataTableDDLService = mock<DataTableDDLService>();
|
mockDataTableDDLService = mock<DataTableDDLService>();
|
||||||
mockWorkflowRepository = mock<WorkflowRepository>();
|
mockUserRepository = mock<UserRepository>();
|
||||||
mockWorkflowPublishHistoryRepository = mock<WorkflowPublishHistoryRepository>();
|
mockWorkflowService = mock<WorkflowService>();
|
||||||
|
|
||||||
// Set up cipher mock
|
// Set up cipher mock
|
||||||
mockCipher.decryptV2 = jest.fn(async (data: string) =>
|
mockCipher.decryptV2 = jest.fn(async (data: string) =>
|
||||||
|
|
@ -116,11 +105,10 @@ describe('ImportService', () => {
|
||||||
mockTagRepository,
|
mockTagRepository,
|
||||||
mockDataSource,
|
mockDataSource,
|
||||||
mockCipher,
|
mockCipher,
|
||||||
mockActiveWorkflowManager,
|
|
||||||
mockWorkflowIndexService,
|
mockWorkflowIndexService,
|
||||||
mockDataTableDDLService,
|
mockDataTableDDLService,
|
||||||
mockWorkflowRepository,
|
mockUserRepository,
|
||||||
mockWorkflowPublishHistoryRepository,
|
mockWorkflowService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1252,6 +1240,234 @@ describe('ImportService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('extractSubworkflowId', () => {
|
||||||
|
it('should extract workflow ID from legacy string format', () => {
|
||||||
|
const node = {
|
||||||
|
parameters: { workflowId: 'abc123' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error accessing private method for testing
|
||||||
|
expect(importService.extractSubworkflowId(node)).toBe('abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract workflow ID from resource-locator object format', () => {
|
||||||
|
const node = {
|
||||||
|
parameters: {
|
||||||
|
workflowId: { __rl: true, value: 'LCEM9GnTcIVSy1D8', mode: 'list' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error accessing private method for testing
|
||||||
|
expect(importService.extractSubworkflowId(node)).toBe('LCEM9GnTcIVSy1D8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when workflowId is missing', () => {
|
||||||
|
const node = {
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error accessing private method for testing
|
||||||
|
expect(importService.extractSubworkflowId(node)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sortWorkflowsForActivation', () => {
|
||||||
|
function makeNode(id: string, type: string, parameters: Record<string, unknown> = {}) {
|
||||||
|
return { id, type, parameters, disabled: false } as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeExecuteWorkflowNode(id: string, calleeId: string) {
|
||||||
|
return makeNode(id, 'n8n-nodes-base.executeWorkflow', {
|
||||||
|
workflowId: calleeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWorkflow(id: string, nodes: any[] = []) {
|
||||||
|
return { id, nodes } as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeToActivate(ids: string[]) {
|
||||||
|
return ids.map((id) => ({ workflowId: id, versionId: `v-${id}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should return single workflow unchanged', () => {
|
||||||
|
const workflows = [makeWorkflow('A')];
|
||||||
|
const toActivate = makeToActivate(['A']);
|
||||||
|
|
||||||
|
// @ts-expect-error accessing private method for testing
|
||||||
|
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
|
||||||
|
|
||||||
|
expect(result.map((w) => w.workflowId)).toEqual(['A']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should activate callee (B) before caller (A) — simple A→B case', () => {
|
||||||
|
const workflows = [
|
||||||
|
makeWorkflow('A', [makeExecuteWorkflowNode('n1', 'B')]),
|
||||||
|
makeWorkflow('B'),
|
||||||
|
];
|
||||||
|
const toActivate = makeToActivate(['A', 'B']);
|
||||||
|
|
||||||
|
// @ts-expect-error accessing private method for testing
|
||||||
|
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
|
||||||
|
|
||||||
|
expect(result.map((w) => w.workflowId)).toEqual(['B', 'A']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should activate C → B → A for a three-level chain', () => {
|
||||||
|
const workflows = [
|
||||||
|
makeWorkflow('A', [makeExecuteWorkflowNode('n1', 'B')]),
|
||||||
|
makeWorkflow('B', [makeExecuteWorkflowNode('n2', 'C')]),
|
||||||
|
makeWorkflow('C'),
|
||||||
|
];
|
||||||
|
const toActivate = makeToActivate(['A', 'B', 'C']);
|
||||||
|
|
||||||
|
// @ts-expect-error accessing private method for testing
|
||||||
|
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
|
||||||
|
|
||||||
|
expect(result.map((w) => w.workflowId)).toEqual(['C', 'B', 'A']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should activate both B and C before A when A calls both', () => {
|
||||||
|
const workflows = [
|
||||||
|
makeWorkflow('A', [makeExecuteWorkflowNode('n1', 'B'), makeExecuteWorkflowNode('n2', 'C')]),
|
||||||
|
makeWorkflow('B'),
|
||||||
|
makeWorkflow('C'),
|
||||||
|
];
|
||||||
|
const toActivate = makeToActivate(['A', 'B', 'C']);
|
||||||
|
|
||||||
|
// @ts-expect-error accessing private method for testing
|
||||||
|
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
|
||||||
|
|
||||||
|
const ids = result.map((w) => w.workflowId);
|
||||||
|
expect(ids.indexOf('B')).toBeLessThan(ids.indexOf('A'));
|
||||||
|
expect(ids.indexOf('C')).toBeLessThan(ids.indexOf('A'));
|
||||||
|
expect(ids).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore referenced workflows not present in the activation batch', () => {
|
||||||
|
const workflows = [
|
||||||
|
makeWorkflow('A', [makeExecuteWorkflowNode('n1', 'EXTERNAL')]),
|
||||||
|
makeWorkflow('B'),
|
||||||
|
];
|
||||||
|
const toActivate = makeToActivate(['A', 'B']);
|
||||||
|
|
||||||
|
// @ts-expect-error accessing private method for testing
|
||||||
|
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((w) => w.workflowId)).toContain('A');
|
||||||
|
expect(result.map((w) => w.workflowId)).toContain('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip disabled executeWorkflow nodes when building the dependency graph', () => {
|
||||||
|
const disabledNode = { ...makeExecuteWorkflowNode('n1', 'B'), disabled: true };
|
||||||
|
const workflows = [makeWorkflow('A', [disabledNode]), makeWorkflow('B')];
|
||||||
|
const toActivate = makeToActivate(['A', 'B']);
|
||||||
|
|
||||||
|
// @ts-expect-error accessing private method for testing
|
||||||
|
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
|
||||||
|
|
||||||
|
// A has no active dependencies on B, so order can be anything — just verify both present
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle resource-locator workflowId format in nodes', () => {
|
||||||
|
const node = makeNode('n1', 'n8n-nodes-base.executeWorkflow', {
|
||||||
|
workflowId: { __rl: true, value: 'B', mode: 'list' },
|
||||||
|
});
|
||||||
|
const workflows = [makeWorkflow('A', [node]), makeWorkflow('B')];
|
||||||
|
const toActivate = makeToActivate(['A', 'B']);
|
||||||
|
|
||||||
|
// @ts-expect-error accessing private method for testing
|
||||||
|
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
|
||||||
|
|
||||||
|
expect(result.map((w) => w.workflowId)).toEqual(['B', 'A']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original order (fast path) when no workflow references another batch workflow', () => {
|
||||||
|
// Both workflows have executeWorkflow nodes, but they point to external IDs not in batch
|
||||||
|
const workflows = [
|
||||||
|
makeWorkflow('A', [makeExecuteWorkflowNode('n1', 'EXTERNAL_1')]),
|
||||||
|
makeWorkflow('B', [makeExecuteWorkflowNode('n2', 'EXTERNAL_2')]),
|
||||||
|
];
|
||||||
|
const toActivate = makeToActivate(['A', 'B']);
|
||||||
|
|
||||||
|
// @ts-expect-error accessing private method for testing
|
||||||
|
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
|
||||||
|
|
||||||
|
// Same reference → fast path returned the original array unchanged
|
||||||
|
expect(result).toBe(toActivate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original order (fast path) when no workflows have executeWorkflow nodes', () => {
|
||||||
|
const workflows = [makeWorkflow('A'), makeWorkflow('B'), makeWorkflow('C')];
|
||||||
|
const toActivate = makeToActivate(['A', 'B', 'C']);
|
||||||
|
|
||||||
|
// @ts-expect-error accessing private method for testing
|
||||||
|
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
|
||||||
|
|
||||||
|
expect(result).toBe(toActivate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append mutually-cyclic workflows (A↔B) and log a warning', () => {
|
||||||
|
const workflows = [
|
||||||
|
makeWorkflow('A', [makeExecuteWorkflowNode('n1', 'B')]),
|
||||||
|
makeWorkflow('B', [makeExecuteWorkflowNode('n2', 'A')]),
|
||||||
|
];
|
||||||
|
const toActivate = makeToActivate(['A', 'B']);
|
||||||
|
|
||||||
|
// @ts-expect-error accessing private method for testing
|
||||||
|
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((w) => w.workflowId)).toContain('A');
|
||||||
|
expect(result.map((w) => w.workflowId)).toContain('B');
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('circular'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append all workflows in a three-way cycle (A→B→C→A) and log a warning', () => {
|
||||||
|
const workflows = [
|
||||||
|
makeWorkflow('A', [makeExecuteWorkflowNode('n1', 'B')]),
|
||||||
|
makeWorkflow('B', [makeExecuteWorkflowNode('n2', 'C')]),
|
||||||
|
makeWorkflow('C', [makeExecuteWorkflowNode('n3', 'A')]),
|
||||||
|
];
|
||||||
|
const toActivate = makeToActivate(['A', 'B', 'C']);
|
||||||
|
|
||||||
|
// @ts-expect-error accessing private method for testing
|
||||||
|
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result.map((w) => w.workflowId)).toContain('A');
|
||||||
|
expect(result.map((w) => w.workflowId)).toContain('B');
|
||||||
|
expect(result.map((w) => w.workflowId)).toContain('C');
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('circular'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort non-cyclic workflows first and append cyclic workflows at the end', () => {
|
||||||
|
// D→E: normal dependency, no cycle
|
||||||
|
// A↔B: mutual cycle
|
||||||
|
const workflows = [
|
||||||
|
makeWorkflow('D', [makeExecuteWorkflowNode('n1', 'E')]),
|
||||||
|
makeWorkflow('E'),
|
||||||
|
makeWorkflow('A', [makeExecuteWorkflowNode('n2', 'B')]),
|
||||||
|
makeWorkflow('B', [makeExecuteWorkflowNode('n3', 'A')]),
|
||||||
|
];
|
||||||
|
const toActivate = makeToActivate(['D', 'E', 'A', 'B']);
|
||||||
|
|
||||||
|
// @ts-expect-error accessing private method for testing
|
||||||
|
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
const ids = result.map((w) => w.workflowId);
|
||||||
|
// E must come before D (normal dependency order)
|
||||||
|
expect(ids.indexOf('E')).toBeLessThan(ids.indexOf('D'));
|
||||||
|
// A and B are cyclic — they appear after the sorted pair
|
||||||
|
expect(ids.indexOf('A')).toBeGreaterThan(ids.indexOf('D'));
|
||||||
|
expect(ids.indexOf('B')).toBeGreaterThan(ids.indexOf('D'));
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('circular'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('advanceIdentitySequences', () => {
|
describe('advanceIdentitySequences', () => {
|
||||||
it('should run setval for each identity column on Postgres', async () => {
|
it('should run setval for each identity column on Postgres', async () => {
|
||||||
// @ts-expect-error overriding for the test
|
// @ts-expect-error overriding for the test
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Logger, safeJoinPath } from '@n8n/backend-common';
|
import { Logger, safeJoinPath } from '@n8n/backend-common';
|
||||||
import type { TagEntity, ICredentialsDb } from '@n8n/db';
|
import type { TagEntity, ICredentialsDb, User } from '@n8n/db';
|
||||||
import {
|
import {
|
||||||
Project,
|
Project,
|
||||||
WorkflowEntity,
|
WorkflowEntity,
|
||||||
|
|
@ -7,15 +7,15 @@ import {
|
||||||
WorkflowTagMapping,
|
WorkflowTagMapping,
|
||||||
CredentialsRepository,
|
CredentialsRepository,
|
||||||
TagRepository,
|
TagRepository,
|
||||||
|
UserRepository,
|
||||||
WorkflowHistory,
|
WorkflowHistory,
|
||||||
WorkflowPublishHistory,
|
|
||||||
WorkflowPublishHistoryRepository,
|
|
||||||
WorkflowRepository,
|
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
|
import { Service } from '@n8n/di';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
import { DataSource, EntityManager, In, type EntityMetadata } from '@n8n/typeorm';
|
import { DataSource, EntityManager, In, type EntityMetadata } from '@n8n/typeorm';
|
||||||
import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity';
|
import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity';
|
||||||
import { Service } from '@n8n/di';
|
import { readdir, readFile } from 'fs/promises';
|
||||||
|
import { Cipher } from 'n8n-core';
|
||||||
import {
|
import {
|
||||||
ensureError,
|
ensureError,
|
||||||
type INode,
|
type INode,
|
||||||
|
|
@ -23,23 +23,21 @@ import {
|
||||||
type IWorkflowBase,
|
type IWorkflowBase,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { readdir, readFile } from 'fs/promises';
|
|
||||||
|
|
||||||
import { replaceInvalidCredentials, validateWorkflowStructure } from '@/workflow-helpers';
|
|
||||||
import { validateDbTypeForImportEntities } from '@/utils/validate-database-type';
|
|
||||||
import { Cipher } from 'n8n-core';
|
|
||||||
import { decompressFolder } from '@/utils/compression.util';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
|
||||||
import type { IWorkflowWithVersionMetadata } from '@/interfaces';
|
import type { IWorkflowWithVersionMetadata } from '@/interfaces';
|
||||||
import { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
|
|
||||||
import { DataTableDDLService } from '@/modules/data-table/data-table-ddl.service';
|
|
||||||
import type { DataTableColumn } from '@/modules/data-table/data-table-column.entity';
|
import type { DataTableColumn } from '@/modules/data-table/data-table-column.entity';
|
||||||
|
import { DataTableDDLService } from '@/modules/data-table/data-table-ddl.service';
|
||||||
import {
|
import {
|
||||||
normalizeUserRowValueForDatabase,
|
normalizeUserRowValueForDatabase,
|
||||||
quoteIdentifier,
|
quoteIdentifier,
|
||||||
toTableName,
|
toTableName,
|
||||||
} from '@/modules/data-table/utils/sql-utils';
|
} from '@/modules/data-table/utils/sql-utils';
|
||||||
|
import { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
|
||||||
|
import { decompressFolder } from '@/utils/compression.util';
|
||||||
|
import { validateDbTypeForImportEntities } from '@/utils/validate-database-type';
|
||||||
|
import { replaceInvalidCredentials, validateWorkflowStructure } from '@/workflow-helpers';
|
||||||
|
import { WorkflowService } from '@/workflows/workflow.service';
|
||||||
|
|
||||||
const DATA_TABLE_ROWS_FILE_PREFIX = 'data_table_user_';
|
const DATA_TABLE_ROWS_FILE_PREFIX = 'data_table_user_';
|
||||||
|
|
||||||
|
|
@ -72,11 +70,10 @@ export class ImportService {
|
||||||
private readonly tagRepository: TagRepository,
|
private readonly tagRepository: TagRepository,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly cipher: Cipher,
|
private readonly cipher: Cipher,
|
||||||
private readonly activeWorkflowManager: ActiveWorkflowManager,
|
|
||||||
private readonly workflowIndexService: WorkflowIndexService,
|
private readonly workflowIndexService: WorkflowIndexService,
|
||||||
private readonly dataTableDDLService: DataTableDDLService,
|
private readonly dataTableDDLService: DataTableDDLService,
|
||||||
private readonly workflowRepository: WorkflowRepository,
|
private readonly userRepository: UserRepository,
|
||||||
private readonly workflowPublishHistoryRepository: WorkflowPublishHistoryRepository,
|
private readonly workflowService: WorkflowService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async initRecords() {
|
async initRecords() {
|
||||||
|
|
@ -87,10 +84,16 @@ export class ImportService {
|
||||||
async importWorkflows(
|
async importWorkflows(
|
||||||
workflows: IWorkflowWithVersionMetadata[],
|
workflows: IWorkflowWithVersionMetadata[],
|
||||||
projectId: string,
|
projectId: string,
|
||||||
{ activeState = 'false' }: { activeState?: 'false' | 'fromJson' } = {},
|
userId: string,
|
||||||
|
{ activeState = 'false' }: { activeState?: 'false' | 'fromJson' },
|
||||||
) {
|
) {
|
||||||
await this.initRecords();
|
await this.initRecords();
|
||||||
|
|
||||||
|
const user = await this.userRepository.findOneOrFail({
|
||||||
|
where: { id: userId },
|
||||||
|
relations: ['role'],
|
||||||
|
});
|
||||||
|
|
||||||
const { manager: dbManager } = this.credentialsRepository;
|
const { manager: dbManager } = this.credentialsRepository;
|
||||||
|
|
||||||
// Check existence and active status of all workflows
|
// Check existence and active status of all workflows
|
||||||
|
|
@ -124,18 +127,16 @@ export class ImportService {
|
||||||
if (hasInvalidCreds) await this.replaceInvalidCreds(workflow, projectId);
|
if (hasInvalidCreds) await this.replaceInvalidCreds(workflow, projectId);
|
||||||
validateWorkflowStructure(workflow);
|
validateWorkflowStructure(workflow);
|
||||||
|
|
||||||
// Remove workflows from ActiveWorkflowManager BEFORE transaction to prevent orphaned trigger listeners
|
// Deactivate BEFORE the transaction to prevent orphaned trigger listeners.
|
||||||
// Only remove if the workflow already exists in the database and is active
|
// Only applies to workflows that are currently active in the database.
|
||||||
if (workflow.id && activeVersionIdByWorkflow.has(workflow.id)) {
|
if (workflow.id && activeVersionIdByWorkflow.has(workflow.id)) {
|
||||||
await this.activeWorkflowManager.remove(workflow.id);
|
await this.workflowService.deactivateWorkflow(user, workflow.id, { source: 'import' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertedWorkflows: IWorkflowWithVersionMetadata[] = [];
|
const insertedWorkflows: IWorkflowWithVersionMetadata[] = [];
|
||||||
const workflowsToActivate: Array<{ workflowId: string; versionId: string }> = [];
|
const workflowsToActivate: Array<{ workflowId: string; versionId: string }> = [];
|
||||||
await dbManager.transaction(async (tx) => {
|
await dbManager.transaction(async (tx) => {
|
||||||
const workflowsNeedingPublishHistory: Array<{ workflowId: string; versionId: string }> = [];
|
|
||||||
|
|
||||||
// Upsert all workflows
|
// Upsert all workflows
|
||||||
for (const workflow of workflows) {
|
for (const workflow of workflows) {
|
||||||
// Always generate a new versionId on import to ensure proper history ordering
|
// Always generate a new versionId on import to ensure proper history ordering
|
||||||
|
|
@ -163,11 +164,6 @@ export class ImportService {
|
||||||
const workflowId = upsertResult.identifiers.at(0)?.id as string;
|
const workflowId = upsertResult.identifiers.at(0)?.id as string;
|
||||||
insertedWorkflows.push({ ...workflow, id: workflowId }); // Collect inserted workflow with correct ID, for indexing later.
|
insertedWorkflows.push({ ...workflow, id: workflowId }); // Collect inserted workflow with correct ID, for indexing later.
|
||||||
|
|
||||||
// Only add publish history if workflow was previously active
|
|
||||||
if (oldActiveVersionId) {
|
|
||||||
workflowsNeedingPublishHistory.push({ workflowId, versionId: oldActiveVersionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldActivate) {
|
if (shouldActivate) {
|
||||||
workflowsToActivate.push({ workflowId, versionId: versionIdToActivate });
|
workflowsToActivate.push({ workflowId, versionId: versionIdToActivate });
|
||||||
}
|
}
|
||||||
|
|
@ -210,20 +206,14 @@ export class ImportService {
|
||||||
description: versionMetadata?.description ?? null,
|
description: versionMetadata?.description ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add publish history records for workflows that were deactivated
|
|
||||||
for (const { workflowId, versionId } of workflowsNeedingPublishHistory) {
|
|
||||||
await tx.insert(WorkflowPublishHistory, {
|
|
||||||
workflowId,
|
|
||||||
versionId,
|
|
||||||
event: 'deactivated',
|
|
||||||
userId: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const { workflowId, versionId } of workflowsToActivate) {
|
const orderedWorkflowsToActivate = this.sortWorkflowsForActivation(
|
||||||
await this.activateWorkflow(workflowId, versionId);
|
insertedWorkflows,
|
||||||
|
workflowsToActivate,
|
||||||
|
);
|
||||||
|
for (const { workflowId, versionId } of orderedWorkflowsToActivate) {
|
||||||
|
await this.activateWorkflow(workflowId, versionId, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Directly update the index for the important workflows, since they don't generate
|
// Directly update the index for the important workflows, since they don't generate
|
||||||
|
|
@ -233,29 +223,101 @@ export class ImportService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async activateWorkflow(workflowId: string, versionIdToActivate: string): Promise<void> {
|
/**
|
||||||
let didActivate = false;
|
* Sorts workflows to activate in dependency order so that subworkflows are activated
|
||||||
try {
|
* before the workflows that call them. Uses Kahn's topological sort algorithm.
|
||||||
await this.workflowRepository.update(
|
*/
|
||||||
{ id: workflowId },
|
private sortWorkflowsForActivation(
|
||||||
{ activeVersionId: versionIdToActivate },
|
allImportedWorkflows: IWorkflowWithVersionMetadata[],
|
||||||
|
toActivate: Array<{ workflowId: string; versionId: string }>,
|
||||||
|
): Array<{ workflowId: string; versionId: string }> {
|
||||||
|
if (toActivate.length <= 1) return toActivate;
|
||||||
|
|
||||||
|
const nodesByWorkflowId = new Map(allImportedWorkflows.map((w) => [w.id, w.nodes]));
|
||||||
|
const activateIds = new Set(toActivate.map((w) => w.workflowId));
|
||||||
|
|
||||||
|
// Fast path: skip the full graph build if no workflow in the batch references
|
||||||
|
// another batch workflow via an active executeWorkflow node.
|
||||||
|
const hasCrossReference = toActivate.some(({ workflowId }) =>
|
||||||
|
(nodesByWorkflowId.get(workflowId) ?? []).some(
|
||||||
|
(node) =>
|
||||||
|
!node.disabled &&
|
||||||
|
node.type === 'n8n-nodes-base.executeWorkflow' &&
|
||||||
|
activateIds.has(this.extractSubworkflowId(node) ?? ''),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await this.workflowRepository.updateActiveState(workflowId, true);
|
if (!hasCrossReference) return toActivate;
|
||||||
await this.activeWorkflowManager.add(workflowId, 'activate');
|
|
||||||
didActivate = true;
|
const toActivateByWorkflowId = new Map(toActivate.map((w) => [w.workflowId, w]));
|
||||||
} catch (e) {
|
// callee id → set of caller ids that depend on it being activated first
|
||||||
const error = ensureError(e);
|
const dependents = new Map<string, Set<string>>(
|
||||||
this.logger.error(`Failed to activate workflow ${workflowId}`, { error });
|
toActivate.map(({ workflowId }) => [workflowId, new Set()]),
|
||||||
} finally {
|
);
|
||||||
if (didActivate) {
|
// caller id → how many of its subworkflow dependencies in this batch are not yet activated
|
||||||
await this.workflowPublishHistoryRepository.addRecord({
|
const unresolvedDepsCount = new Map<string, number>(
|
||||||
workflowId,
|
toActivate.map(({ workflowId }) => [workflowId, 0]),
|
||||||
versionId: versionIdToActivate,
|
);
|
||||||
event: 'activated',
|
|
||||||
userId: null,
|
for (const { workflowId } of toActivate) {
|
||||||
});
|
for (const node of nodesByWorkflowId.get(workflowId) ?? []) {
|
||||||
|
if (node.disabled || node.type !== 'n8n-nodes-base.executeWorkflow') continue;
|
||||||
|
const calleeId = this.extractSubworkflowId(node);
|
||||||
|
if (!calleeId || !activateIds.has(calleeId) || calleeId === workflowId) continue;
|
||||||
|
dependents.get(calleeId)!.add(workflowId);
|
||||||
|
unresolvedDepsCount.set(workflowId, unresolvedDepsCount.get(workflowId)! + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queue = toActivate.filter((w) => unresolvedDepsCount.get(w.workflowId) === 0);
|
||||||
|
const result: Array<{ workflowId: string; versionId: string }> = [];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const item = queue.shift()!;
|
||||||
|
result.push(item);
|
||||||
|
for (const callerId of dependents.get(item.workflowId)!) {
|
||||||
|
const remaining = unresolvedDepsCount.get(callerId)! - 1;
|
||||||
|
unresolvedDepsCount.set(callerId, remaining);
|
||||||
|
if (remaining === 0) queue.push(toActivateByWorkflowId.get(callerId)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.length < toActivate.length) {
|
||||||
|
// Any workflow still with unresolvedDepsCount > 0 was never enqueued by the
|
||||||
|
// sort — it's part of a cycle (its dependency also waits on it). Append these
|
||||||
|
// so they still get activated rather than being silently dropped.
|
||||||
|
const cycleWorkflows = toActivate.filter(
|
||||||
|
(w) => Number(unresolvedDepsCount.get(w.workflowId)) > 0,
|
||||||
|
);
|
||||||
|
this.logger.warn(
|
||||||
|
`Detected circular subworkflow references among workflows: [${cycleWorkflows.map((w) => w.workflowId).join(', ')}]. Activating them in original order.`,
|
||||||
|
);
|
||||||
|
result.push(...cycleWorkflows);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractSubworkflowId(node: INode): string | undefined {
|
||||||
|
const source = node.parameters?.['source'];
|
||||||
|
if (source === 'parameter' || source === 'localFile' || source === 'url') return undefined;
|
||||||
|
const wfId = node.parameters?.['workflowId'];
|
||||||
|
const rawId = typeof wfId === 'string' ? wfId : (wfId as { value?: unknown } | null)?.value;
|
||||||
|
return typeof rawId === 'string' && !rawId.startsWith('=') ? rawId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async activateWorkflow(
|
||||||
|
workflowId: string,
|
||||||
|
versionIdToActivate: string,
|
||||||
|
user: User,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.workflowService.activateWorkflow(user, workflowId, {
|
||||||
|
versionId: versionIdToActivate,
|
||||||
|
source: 'import',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`Failed to activate workflow ${workflowId}`, { error: ensureError(e) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async replaceInvalidCreds(workflow: IWorkflowBase, projectId: string) {
|
async replaceInvalidCreds(workflow: IWorkflowBase, projectId: string) {
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,15 @@ import {
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import { WorkflowPublishHistoryRepository, WorkflowHistoryRepository } from '@n8n/db';
|
import { WorkflowPublishHistoryRepository, WorkflowHistoryRepository } from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
|
import type { INodeType } from 'n8n-workflow';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
import '@/zod-alias-support';
|
import '@/zod-alias-support';
|
||||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||||
import { ImportWorkflowsCommand } from '@/commands/import/workflow';
|
import { ImportWorkflowsCommand } from '@/commands/import/workflow';
|
||||||
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||||
|
import { NodeTypes } from '@/node-types';
|
||||||
|
import { WorkflowService } from '@/workflows/workflow.service';
|
||||||
import { setupTestCommand } from '@test-integration/utils/test-command';
|
import { setupTestCommand } from '@test-integration/utils/test-command';
|
||||||
|
|
||||||
import { createMember, createOwner } from '../shared/db/users';
|
import { createMember, createOwner } from '../shared/db/users';
|
||||||
|
|
@ -21,6 +24,7 @@ import { createMember, createOwner } from '../shared/db/users';
|
||||||
mockInstance(LoadNodesAndCredentials);
|
mockInstance(LoadNodesAndCredentials);
|
||||||
mockInstance(ActiveWorkflowManager);
|
mockInstance(ActiveWorkflowManager);
|
||||||
mockInstance(WorkflowPublishHistoryRepository);
|
mockInstance(WorkflowPublishHistoryRepository);
|
||||||
|
const mockNodeTypes = mockInstance(NodeTypes);
|
||||||
|
|
||||||
const command = setupTestCommand(ImportWorkflowsCommand);
|
const command = setupTestCommand(ImportWorkflowsCommand);
|
||||||
|
|
||||||
|
|
@ -385,6 +389,27 @@ describe('--activeState flag', () => {
|
||||||
globalConfig.executions.mode = originalMode;
|
globalConfig.executions.mode = originalMode;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO: fix this workaround being needed for these tests to run.
|
||||||
|
// It was introduced after refactoring the ImportService used by the import command
|
||||||
|
// from using the ActiveWorkflowManager to activate/deactivate workflows to the WorkflowService.
|
||||||
|
beforeEach(() => {
|
||||||
|
// Bypass webhook conflict detection to avoid infrastructure dependencies
|
||||||
|
// (getWorkflowExecutionData → VariablesService.getAllCached → CacheService/Redis)
|
||||||
|
jest
|
||||||
|
.spyOn(Container.get(WorkflowService) as any, '_findConflictingWebhooks')
|
||||||
|
.mockResolvedValue([]);
|
||||||
|
|
||||||
|
mockNodeTypes.getByNameAndVersion.mockImplementation((nodeType) => {
|
||||||
|
if (nodeType === 'n8n-nodes-base.webhook') {
|
||||||
|
return {
|
||||||
|
description: { webhooks: undefined, properties: [] },
|
||||||
|
webhook: jest.fn(),
|
||||||
|
} as unknown as INodeType;
|
||||||
|
}
|
||||||
|
return { description: { properties: [] } } as unknown as INodeType;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('fromJson', () => {
|
describe('fromJson', () => {
|
||||||
it('should activate a workflow that is marked as active in the imported json', async () => {
|
it('should activate a workflow that is marked as active in the imported json', async () => {
|
||||||
await createOwner();
|
await createOwner();
|
||||||
|
|
@ -410,7 +435,7 @@ describe('--activeState flag', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should deactivate the previously active version and activate the new version when importing a workflow json with an ID that already exists for an active workflow', async () => {
|
it('should deactivate the previously active version and activate the new version when importing a workflow json with an ID that already exists for an active workflow', async () => {
|
||||||
await createOwner();
|
const owner = await createOwner();
|
||||||
|
|
||||||
await command.run([
|
await command.run([
|
||||||
'--input=./test/integration/commands/import-workflows/combined-with-update/original.json',
|
'--input=./test/integration/commands/import-workflows/combined-with-update/original.json',
|
||||||
|
|
@ -441,12 +466,12 @@ describe('--activeState flag', () => {
|
||||||
expect(activeWorkflowManager.add).toHaveBeenLastCalledWith('998', 'activate');
|
expect(activeWorkflowManager.add).toHaveBeenLastCalledWith('998', 'activate');
|
||||||
|
|
||||||
const publishHistoryRepo = Container.get(WorkflowPublishHistoryRepository);
|
const publishHistoryRepo = Container.get(WorkflowPublishHistoryRepository);
|
||||||
expect(publishHistoryRepo.addRecord).toHaveBeenCalledTimes(2);
|
expect(publishHistoryRepo.addRecord).toHaveBeenCalledTimes(3);
|
||||||
expect(publishHistoryRepo.addRecord).toHaveBeenLastCalledWith({
|
expect(publishHistoryRepo.addRecord).toHaveBeenLastCalledWith({
|
||||||
workflowId: '998',
|
workflowId: '998',
|
||||||
versionId: second.versionId,
|
versionId: second.versionId,
|
||||||
event: 'activated',
|
event: 'activated',
|
||||||
userId: null,
|
userId: owner.id,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,16 @@ import {
|
||||||
SharedWorkflowRepository,
|
SharedWorkflowRepository,
|
||||||
WorkflowRepository,
|
WorkflowRepository,
|
||||||
WorkflowHistoryRepository,
|
WorkflowHistoryRepository,
|
||||||
WorkflowPublishHistoryRepository,
|
UserRepository,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import type { INode } from 'n8n-workflow';
|
import type { INode } from 'n8n-workflow';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
|
|
||||||
import type { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
|
import type { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
|
||||||
import { ImportService } from '@/services/import.service';
|
import { ImportService } from '@/services/import.service';
|
||||||
|
import type { WorkflowService } from '@/workflows/workflow.service';
|
||||||
|
|
||||||
import { createMember, createOwner } from './shared/db/users';
|
import { createMember, createOwner } from './shared/db/users';
|
||||||
|
|
||||||
|
|
@ -34,13 +34,12 @@ describe('ImportService', () => {
|
||||||
let tagRepository: TagRepository;
|
let tagRepository: TagRepository;
|
||||||
let owner: User;
|
let owner: User;
|
||||||
let ownerPersonalProject: Project;
|
let ownerPersonalProject: Project;
|
||||||
let mockActiveWorkflowManager: ActiveWorkflowManager;
|
let mockWorkflowService: jest.Mocked<WorkflowService>;
|
||||||
let mockWorkflowIndexService: WorkflowIndexService;
|
let mockWorkflowIndexService: WorkflowIndexService;
|
||||||
|
|
||||||
let workflowRepository: WorkflowRepository;
|
let workflowRepository: WorkflowRepository;
|
||||||
let sharedWorkflowRepository: SharedWorkflowRepository;
|
let sharedWorkflowRepository: SharedWorkflowRepository;
|
||||||
let workflowHistoryRepository: WorkflowHistoryRepository;
|
let workflowHistoryRepository: WorkflowHistoryRepository;
|
||||||
let workflowPublishHistoryRepository: WorkflowPublishHistoryRepository;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await testDb.init();
|
await testDb.init();
|
||||||
|
|
@ -48,7 +47,6 @@ describe('ImportService', () => {
|
||||||
workflowRepository = Container.get(WorkflowRepository);
|
workflowRepository = Container.get(WorkflowRepository);
|
||||||
sharedWorkflowRepository = Container.get(SharedWorkflowRepository);
|
sharedWorkflowRepository = Container.get(SharedWorkflowRepository);
|
||||||
workflowHistoryRepository = Container.get(WorkflowHistoryRepository);
|
workflowHistoryRepository = Container.get(WorkflowHistoryRepository);
|
||||||
workflowPublishHistoryRepository = Container.get(WorkflowPublishHistoryRepository);
|
|
||||||
|
|
||||||
owner = await createOwner();
|
owner = await createOwner();
|
||||||
ownerPersonalProject = await getPersonalProject(owner);
|
ownerPersonalProject = await getPersonalProject(owner);
|
||||||
|
|
@ -56,9 +54,9 @@ describe('ImportService', () => {
|
||||||
tagRepository = Container.get(TagRepository);
|
tagRepository = Container.get(TagRepository);
|
||||||
|
|
||||||
const credentialsRepository = Container.get(CredentialsRepository);
|
const credentialsRepository = Container.get(CredentialsRepository);
|
||||||
|
const userRepository = Container.get(UserRepository);
|
||||||
|
|
||||||
mockActiveWorkflowManager = mock<ActiveWorkflowManager>();
|
mockWorkflowService = mock<WorkflowService>();
|
||||||
|
|
||||||
mockWorkflowIndexService = mock<WorkflowIndexService>();
|
mockWorkflowIndexService = mock<WorkflowIndexService>();
|
||||||
|
|
||||||
importService = new ImportService(
|
importService = new ImportService(
|
||||||
|
|
@ -67,11 +65,10 @@ describe('ImportService', () => {
|
||||||
tagRepository,
|
tagRepository,
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
mockActiveWorkflowManager,
|
|
||||||
mockWorkflowIndexService,
|
mockWorkflowIndexService,
|
||||||
mock(),
|
mock(),
|
||||||
workflowRepository,
|
userRepository,
|
||||||
workflowPublishHistoryRepository,
|
mockWorkflowService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -93,7 +90,7 @@ describe('ImportService', () => {
|
||||||
test('should import credless and tagless workflow', async () => {
|
test('should import credless and tagless workflow', async () => {
|
||||||
const workflowToImport = await createWorkflow();
|
const workflowToImport = await createWorkflow();
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id);
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
|
||||||
|
|
||||||
const dbWorkflow = await getWorkflowById(workflowToImport.id);
|
const dbWorkflow = await getWorkflowById(workflowToImport.id);
|
||||||
|
|
||||||
|
|
@ -106,7 +103,7 @@ describe('ImportService', () => {
|
||||||
test('should make user owner of imported workflow', async () => {
|
test('should make user owner of imported workflow', async () => {
|
||||||
const workflowToImport = newWorkflow();
|
const workflowToImport = newWorkflow();
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id);
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
|
||||||
|
|
||||||
const dbSharing = await sharedWorkflowRepository.findOneOrFail({
|
const dbSharing = await sharedWorkflowRepository.findOneOrFail({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -124,7 +121,7 @@ describe('ImportService', () => {
|
||||||
const memberPersonalProject = await getPersonalProject(member);
|
const memberPersonalProject = await getPersonalProject(member);
|
||||||
const workflowToImport = await createWorkflow(undefined, owner);
|
const workflowToImport = await createWorkflow(undefined, owner);
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], memberPersonalProject.id);
|
await importService.importWorkflows([workflowToImport], memberPersonalProject.id, owner.id, {});
|
||||||
|
|
||||||
const sharings = await getAllSharedWorkflows();
|
const sharings = await getAllSharedWorkflows();
|
||||||
|
|
||||||
|
|
@ -140,7 +137,7 @@ describe('ImportService', () => {
|
||||||
test('should deactivate imported workflow if active', async () => {
|
test('should deactivate imported workflow if active', async () => {
|
||||||
const workflowToImport = await createActiveWorkflow();
|
const workflowToImport = await createActiveWorkflow();
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id);
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
|
||||||
|
|
||||||
const dbWorkflow = await getWorkflowById(workflowToImport.id);
|
const dbWorkflow = await getWorkflowById(workflowToImport.id);
|
||||||
|
|
||||||
|
|
@ -169,7 +166,7 @@ describe('ImportService', () => {
|
||||||
|
|
||||||
const workflowToImport = await createWorkflow({ nodes });
|
const workflowToImport = await createWorkflow({ nodes });
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id);
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
|
||||||
|
|
||||||
const dbWorkflow = await getWorkflowById(workflowToImport.id);
|
const dbWorkflow = await getWorkflowById(workflowToImport.id);
|
||||||
|
|
||||||
|
|
@ -189,7 +186,7 @@ describe('ImportService', () => {
|
||||||
|
|
||||||
const workflowToImport = await createWorkflow({ tags: [tag] });
|
const workflowToImport = await createWorkflow({ tags: [tag] });
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id);
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
|
||||||
|
|
||||||
const dbWorkflow = await workflowRepository.findOneOrFail({
|
const dbWorkflow = await workflowRepository.findOneOrFail({
|
||||||
where: { id: workflowToImport.id },
|
where: { id: workflowToImport.id },
|
||||||
|
|
@ -210,7 +207,7 @@ describe('ImportService', () => {
|
||||||
|
|
||||||
const workflowToImport = await createWorkflow({ tags: [tag] });
|
const workflowToImport = await createWorkflow({ tags: [tag] });
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id);
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
|
||||||
|
|
||||||
const dbWorkflow = await workflowRepository.findOneOrFail({
|
const dbWorkflow = await workflowRepository.findOneOrFail({
|
||||||
where: { id: workflowToImport.id },
|
where: { id: workflowToImport.id },
|
||||||
|
|
@ -229,7 +226,7 @@ describe('ImportService', () => {
|
||||||
|
|
||||||
const workflowToImport = await createWorkflow({ tags: [tag] });
|
const workflowToImport = await createWorkflow({ tags: [tag] });
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id);
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
|
||||||
|
|
||||||
const dbWorkflow = await workflowRepository.findOneOrFail({
|
const dbWorkflow = await workflowRepository.findOneOrFail({
|
||||||
where: { id: workflowToImport.id },
|
where: { id: workflowToImport.id },
|
||||||
|
|
@ -245,17 +242,21 @@ describe('ImportService', () => {
|
||||||
expect(dbTag.name).toBe(tag.name); // tag created
|
expect(dbTag.name).toBe(tag.name); // tag created
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should remove workflow from ActiveWorkflowManager when workflow has ID', async () => {
|
test('should call WorkflowService.deactivateWorkflow when workflow has ID and is active', async () => {
|
||||||
const workflowWithId = await createActiveWorkflow();
|
const workflowWithId = await createActiveWorkflow();
|
||||||
await importService.importWorkflows([workflowWithId], ownerPersonalProject.id);
|
await importService.importWorkflows([workflowWithId], ownerPersonalProject.id, owner.id, {});
|
||||||
|
|
||||||
expect(mockActiveWorkflowManager.remove).toHaveBeenCalledWith(workflowWithId.id);
|
expect(mockWorkflowService.deactivateWorkflow).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: owner.id }),
|
||||||
|
workflowWithId.id,
|
||||||
|
{ source: 'import' },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should always create a record in workflow history', async () => {
|
test('should always create a record in workflow history', async () => {
|
||||||
const workflowToImport = newWorkflow();
|
const workflowToImport = newWorkflow();
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id);
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
|
||||||
|
|
||||||
const workflowHistoryRecords = await workflowHistoryRepository.find({
|
const workflowHistoryRecords = await workflowHistoryRepository.find({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -277,7 +278,7 @@ describe('ImportService', () => {
|
||||||
description: 'Historical workflow description',
|
description: 'Historical workflow description',
|
||||||
};
|
};
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id);
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
|
||||||
|
|
||||||
const workflowHistoryRecords = await workflowHistoryRepository.find({
|
const workflowHistoryRecords = await workflowHistoryRepository.find({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -290,46 +291,31 @@ describe('ImportService', () => {
|
||||||
expect(workflowHistoryRecords[0].description).toBe('Historical workflow description');
|
expect(workflowHistoryRecords[0].description).toBe('Historical workflow description');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create a record in workflow publish history if active version exists', async () => {
|
test('should call WorkflowService.deactivateWorkflow when re-importing an existing active workflow', async () => {
|
||||||
// Create an existing active workflow in the database first
|
|
||||||
const existingWorkflow = await createActiveWorkflow();
|
const existingWorkflow = await createActiveWorkflow();
|
||||||
const originalActiveVersionId = existingWorkflow.activeVersionId!;
|
|
||||||
|
|
||||||
// Now import it again (simulating re-import of an active workflow)
|
|
||||||
const workflowToImport = await getWorkflowById(existingWorkflow.id);
|
const workflowToImport = await getWorkflowById(existingWorkflow.id);
|
||||||
if (!workflowToImport) fail('Expected to find workflow');
|
if (!workflowToImport) fail('Expected to find workflow');
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id);
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
|
||||||
|
|
||||||
const publishHistoryRecords = await workflowPublishHistoryRepository.find({
|
expect(mockWorkflowService.deactivateWorkflow).toHaveBeenCalledWith(
|
||||||
where: {
|
expect.objectContaining({ id: owner.id }),
|
||||||
workflowId: existingWorkflow.id,
|
existingWorkflow.id,
|
||||||
event: 'deactivated',
|
{ source: 'import' },
|
||||||
},
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should have publish history for deactivating the original active version
|
test('should not call WorkflowService.deactivateWorkflow for new (non-existing) workflows', async () => {
|
||||||
expect(publishHistoryRecords).toHaveLength(1);
|
mockWorkflowService.deactivateWorkflow.mockClear();
|
||||||
expect(publishHistoryRecords[0].versionId).toBe(originalActiveVersionId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not create a record in workflow publish history for new workflows', async () => {
|
|
||||||
const workflowToImport = newWorkflow();
|
const workflowToImport = newWorkflow();
|
||||||
workflowToImport.active = true;
|
workflowToImport.active = true;
|
||||||
workflowToImport.activeVersionId = 'some-version';
|
workflowToImport.activeVersionId = 'some-version';
|
||||||
|
|
||||||
if (!workflowToImport) fail('Expected to find workflow');
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id);
|
expect(mockWorkflowService.deactivateWorkflow).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const publishHistoryRecords = await workflowPublishHistoryRepository.find({
|
|
||||||
where: {
|
|
||||||
workflowId: workflowToImport.id,
|
|
||||||
event: 'deactivated',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(publishHistoryRecords).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should always generate a new versionId when importing, ensuring proper history ordering', async () => {
|
test('should always generate a new versionId when importing, ensuring proper history ordering', async () => {
|
||||||
|
|
@ -340,7 +326,12 @@ describe('ImportService', () => {
|
||||||
const workflowToReimport = await getWorkflowById(initialWorkflow.id);
|
const workflowToReimport = await getWorkflowById(initialWorkflow.id);
|
||||||
if (!workflowToReimport) fail('Expected to find workflow');
|
if (!workflowToReimport) fail('Expected to find workflow');
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToReimport], ownerPersonalProject.id);
|
await importService.importWorkflows(
|
||||||
|
[workflowToReimport],
|
||||||
|
ownerPersonalProject.id,
|
||||||
|
owner.id,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
const historyRecords = await workflowHistoryRepository.find({
|
const historyRecords = await workflowHistoryRepository.find({
|
||||||
where: { workflowId: initialWorkflow.id },
|
where: { workflowId: initialWorkflow.id },
|
||||||
|
|
@ -361,20 +352,19 @@ describe('ImportService', () => {
|
||||||
const workflowToImport = await createWorkflow();
|
const workflowToImport = await createWorkflow();
|
||||||
workflowToImport.active = true;
|
workflowToImport.active = true;
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, {
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {
|
||||||
activeState: 'fromJson',
|
activeState: 'fromJson',
|
||||||
});
|
});
|
||||||
|
|
||||||
const dbWorkflow = await getWorkflowById(workflowToImport.id);
|
expect(mockWorkflowService.activateWorkflow).toHaveBeenCalledWith(
|
||||||
if (!dbWorkflow) fail('Expected to find workflow');
|
expect.objectContaining({ id: owner.id }),
|
||||||
|
workflowToImport.id,
|
||||||
expect(dbWorkflow.active).toBe(true);
|
expect.objectContaining({ source: 'import' }),
|
||||||
expect(dbWorkflow.activeVersionId).toBe(dbWorkflow.versionId);
|
);
|
||||||
expect(mockActiveWorkflowManager.add).toHaveBeenCalledWith(workflowToImport.id, 'activate');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should deactivate imported workflow that is updating existing one when JSON has active=false', async () => {
|
test('should deactivate imported workflow that is updating existing one when JSON has active=false', async () => {
|
||||||
jest.mocked(mockActiveWorkflowManager.add).mockClear();
|
mockWorkflowService.activateWorkflow.mockClear();
|
||||||
|
|
||||||
const existingWorkflow = await createActiveWorkflow();
|
const existingWorkflow = await createActiveWorkflow();
|
||||||
|
|
||||||
|
|
@ -382,7 +372,7 @@ describe('ImportService', () => {
|
||||||
if (!workflowToImport) fail('Expected to find workflow');
|
if (!workflowToImport) fail('Expected to find workflow');
|
||||||
workflowToImport.active = false;
|
workflowToImport.active = false;
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, {
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {
|
||||||
activeState: 'fromJson',
|
activeState: 'fromJson',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -391,16 +381,16 @@ describe('ImportService', () => {
|
||||||
|
|
||||||
expect(dbWorkflow.active).toBe(false);
|
expect(dbWorkflow.active).toBe(false);
|
||||||
expect(dbWorkflow.activeVersionId).toBeNull();
|
expect(dbWorkflow.activeVersionId).toBeNull();
|
||||||
expect(mockActiveWorkflowManager.add).not.toHaveBeenCalled();
|
expect(mockWorkflowService.activateWorkflow).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should leave imported workflow deactivated when JSON has active=false', async () => {
|
test('should leave imported workflow deactivated when JSON has active=false', async () => {
|
||||||
jest.mocked(mockActiveWorkflowManager.add).mockClear();
|
mockWorkflowService.activateWorkflow.mockClear();
|
||||||
|
|
||||||
const workflowToImport = await createWorkflow();
|
const workflowToImport = await createWorkflow();
|
||||||
workflowToImport.active = false;
|
workflowToImport.active = false;
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, {
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {
|
||||||
activeState: 'fromJson',
|
activeState: 'fromJson',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -409,60 +399,58 @@ describe('ImportService', () => {
|
||||||
|
|
||||||
expect(dbWorkflow.active).toBe(false);
|
expect(dbWorkflow.active).toBe(false);
|
||||||
expect(dbWorkflow.activeVersionId).toBeNull();
|
expect(dbWorkflow.activeVersionId).toBeNull();
|
||||||
expect(mockActiveWorkflowManager.add).not.toHaveBeenCalled();
|
expect(mockWorkflowService.activateWorkflow).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should record both deactivated (old) and activated (new) publish history when re-importing an active workflow', async () => {
|
test('should call both deactivateWorkflow and activateWorkflow when re-importing an active workflow', async () => {
|
||||||
|
mockWorkflowService.deactivateWorkflow.mockClear();
|
||||||
|
mockWorkflowService.activateWorkflow.mockClear();
|
||||||
|
|
||||||
const existingWorkflow = await createActiveWorkflow();
|
const existingWorkflow = await createActiveWorkflow();
|
||||||
const originalActiveVersionId = existingWorkflow.activeVersionId!;
|
|
||||||
|
|
||||||
const workflowToImport = await getWorkflowById(existingWorkflow.id);
|
const workflowToImport = await getWorkflowById(existingWorkflow.id);
|
||||||
if (!workflowToImport) fail('Expected to find workflow');
|
if (!workflowToImport) fail('Expected to find workflow');
|
||||||
workflowToImport.active = true;
|
workflowToImport.active = true;
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, {
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {
|
||||||
activeState: 'fromJson',
|
activeState: 'fromJson',
|
||||||
});
|
});
|
||||||
|
|
||||||
const dbWorkflow = await getWorkflowById(existingWorkflow.id);
|
expect(mockWorkflowService.deactivateWorkflow).toHaveBeenCalledWith(
|
||||||
if (!dbWorkflow) fail('Expected to find workflow');
|
expect.objectContaining({ id: owner.id }),
|
||||||
|
existingWorkflow.id,
|
||||||
const deactivatedRecords = await workflowPublishHistoryRepository.find({
|
{ source: 'import' },
|
||||||
where: { workflowId: existingWorkflow.id, event: 'deactivated' },
|
);
|
||||||
});
|
expect(mockWorkflowService.activateWorkflow).toHaveBeenCalledWith(
|
||||||
const activatedForNewVersion = await workflowPublishHistoryRepository.find({
|
expect.objectContaining({ id: owner.id }),
|
||||||
where: {
|
existingWorkflow.id,
|
||||||
workflowId: existingWorkflow.id,
|
expect.objectContaining({ source: 'import' }),
|
||||||
event: 'activated',
|
);
|
||||||
versionId: dbWorkflow.versionId,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(deactivatedRecords).toHaveLength(1);
|
test('should not call WorkflowService.deactivateWorkflow for a brand-new active workflow', async () => {
|
||||||
expect(deactivatedRecords[0].versionId).toBe(originalActiveVersionId);
|
mockWorkflowService.deactivateWorkflow.mockClear();
|
||||||
expect(activatedForNewVersion).toHaveLength(1);
|
mockWorkflowService.activateWorkflow.mockClear();
|
||||||
expect(activatedForNewVersion[0].userId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not call ActiveWorkflowManager.remove for a brand-new active workflow', async () => {
|
|
||||||
jest.mocked(mockActiveWorkflowManager.remove).mockClear();
|
|
||||||
jest.mocked(mockActiveWorkflowManager.add).mockClear();
|
|
||||||
|
|
||||||
const workflowToImport = await createWorkflow();
|
const workflowToImport = await createWorkflow();
|
||||||
workflowToImport.active = true;
|
workflowToImport.active = true;
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, {
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {
|
||||||
activeState: 'fromJson',
|
activeState: 'fromJson',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockActiveWorkflowManager.remove).not.toHaveBeenCalled();
|
expect(mockWorkflowService.deactivateWorkflow).not.toHaveBeenCalled();
|
||||||
expect(mockActiveWorkflowManager.add).toHaveBeenCalledTimes(1);
|
expect(mockWorkflowService.activateWorkflow).toHaveBeenCalledTimes(1);
|
||||||
expect(mockActiveWorkflowManager.add).toHaveBeenCalledWith(workflowToImport.id, 'activate');
|
expect(mockWorkflowService.activateWorkflow).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: owner.id }),
|
||||||
|
workflowToImport.id,
|
||||||
|
expect.objectContaining({ source: 'import' }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should call ActiveWorkflowManager.remove exactly once when re-importing an active workflow', async () => {
|
test('should call WorkflowService.deactivateWorkflow exactly once when re-importing an active workflow', async () => {
|
||||||
jest.mocked(mockActiveWorkflowManager.remove).mockClear();
|
mockWorkflowService.deactivateWorkflow.mockClear();
|
||||||
jest.mocked(mockActiveWorkflowManager.add).mockClear();
|
mockWorkflowService.activateWorkflow.mockClear();
|
||||||
|
|
||||||
const existingWorkflow = await createActiveWorkflow();
|
const existingWorkflow = await createActiveWorkflow();
|
||||||
|
|
||||||
|
|
@ -470,14 +458,22 @@ describe('ImportService', () => {
|
||||||
if (!workflowToImport) fail('Expected to find workflow');
|
if (!workflowToImport) fail('Expected to find workflow');
|
||||||
workflowToImport.active = true;
|
workflowToImport.active = true;
|
||||||
|
|
||||||
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, {
|
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {
|
||||||
activeState: 'fromJson',
|
activeState: 'fromJson',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockActiveWorkflowManager.remove).toHaveBeenCalledTimes(1);
|
expect(mockWorkflowService.deactivateWorkflow).toHaveBeenCalledTimes(1);
|
||||||
expect(mockActiveWorkflowManager.remove).toHaveBeenCalledWith(existingWorkflow.id);
|
expect(mockWorkflowService.deactivateWorkflow).toHaveBeenCalledWith(
|
||||||
expect(mockActiveWorkflowManager.add).toHaveBeenCalledTimes(1);
|
expect.objectContaining({ id: owner.id }),
|
||||||
expect(mockActiveWorkflowManager.add).toHaveBeenCalledWith(existingWorkflow.id, 'activate');
|
existingWorkflow.id,
|
||||||
|
{ source: 'import' },
|
||||||
|
);
|
||||||
|
expect(mockWorkflowService.activateWorkflow).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockWorkflowService.activateWorkflow).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: owner.id }),
|
||||||
|
existingWorkflow.id,
|
||||||
|
expect.objectContaining({ source: 'import' }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/i18n",
|
"name": "@n8n/i18n",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.25.0",
|
"version": "2.25.1",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1239,6 +1239,17 @@
|
||||||
"experiments.instanceAiPromptSuggestionsV2.suggestions.onboardNewHires.prompt": "When a new employee is added to our HR database, send them a welcome email with their first-week schedule, create their Notion onboarding page, and post an intro message in the #welcome Slack channel.",
|
"experiments.instanceAiPromptSuggestionsV2.suggestions.onboardNewHires.prompt": "When a new employee is added to our HR database, send them a welcome email with their first-week schedule, create their Notion onboarding page, and post an intro message in the #welcome Slack channel.",
|
||||||
"experiments.instanceAiPromptSuggestionsV2.suggestions.extractDataFromEmails.label": "Extract data from emails",
|
"experiments.instanceAiPromptSuggestionsV2.suggestions.extractDataFromEmails.label": "Extract data from emails",
|
||||||
"experiments.instanceAiPromptSuggestionsV2.suggestions.extractDataFromEmails.prompt": "When an email arrives in Gmail with a PDF attachment, use Gemini to extract key data such as amounts, dates, or contract terms, save the structured data to a Google Sheet, and move the PDF to the right Google Drive folder.",
|
"experiments.instanceAiPromptSuggestionsV2.suggestions.extractDataFromEmails.prompt": "When an email arrives in Gmail with a PDF attachment, use Gemini to extract key data such as amounts, dates, or contract terms, save the structured data to a Google Sheet, and move the PDF to the right Google Drive folder.",
|
||||||
|
"experiments.instanceAiWorkflowPreviewSuggestions.emptyState.title": "What do you want to automate?",
|
||||||
|
"experiments.instanceAiWorkflowPreviewSuggestions.input.placeholder": "Tell me what to build or ask me a question",
|
||||||
|
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.label": "Score my leads",
|
||||||
|
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.prompt": "When a new lead is created in my CRM, enrich it with Lemlist, score it based on fit, then update the lead if qualified and notify the sales team on Slack.",
|
||||||
|
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.label": "Process invoices",
|
||||||
|
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.prompt": "Every morning, scan Gmail for new invoices, use Claude to extract details and cross-check them against purchase orders in Google Sheets, flag any discrepancies for review, and add all payment due dates to Google Calendar automatically.",
|
||||||
|
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.label": "WhatsApp support agent",
|
||||||
|
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.prompt": "When a customer sends a WhatsApp message, use Gemini to match their question against our FAQ in Google Sheets and reply instantly. If it cannot resolve it, create a support ticket in Notion and alert the team on Slack.",
|
||||||
|
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.label": "Schedule social posts",
|
||||||
|
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.prompt": "Every Monday, read this week's content ideas from a Google Sheet, use Gemini to write tailored content then schedule them to post throughout the week.",
|
||||||
|
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.seeAll": "See all",
|
||||||
"experiments.personalizedTemplatesV3.browseAllTemplates": "Browse our template library",
|
"experiments.personalizedTemplatesV3.browseAllTemplates": "Browse our template library",
|
||||||
"experiments.personalizedTemplatesV3.couldntFind": "Need something different?",
|
"experiments.personalizedTemplatesV3.couldntFind": "Need something different?",
|
||||||
"experiments.personalizedTemplatesV3.exploreTemplates": "Get started with HubSpot workflows:",
|
"experiments.personalizedTemplatesV3.exploreTemplates": "Get started with HubSpot workflows:",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-editor-ui",
|
"name": "n8n-editor-ui",
|
||||||
"version": "2.25.0",
|
"version": "2.25.1",
|
||||||
"description": "Workflow Editor UI for n8n",
|
"description": "Workflow Editor UI for n8n",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,10 @@ export const SURFACE_MCP_TO_NEW_CLOUD_USERS_EXPERIMENT = createExperiment(
|
||||||
|
|
||||||
export const CANVAS_NODES_GROUPING_EXPERIMENT = createExperiment('083_canvas_nodes_grouping');
|
export const CANVAS_NODES_GROUPING_EXPERIMENT = createExperiment('083_canvas_nodes_grouping');
|
||||||
|
|
||||||
|
export const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT = createExperiment(
|
||||||
|
'087_instance_ai_workflow_preview_suggestions',
|
||||||
|
);
|
||||||
|
|
||||||
export const EXPERIMENTS_TO_TRACK = [
|
export const EXPERIMENTS_TO_TRACK = [
|
||||||
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
|
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
|
||||||
TEMPLATE_ONBOARDING_EXPERIMENT.name,
|
TEMPLATE_ONBOARDING_EXPERIMENT.name,
|
||||||
|
|
@ -148,4 +152,5 @@ export const EXPERIMENTS_TO_TRACK = [
|
||||||
FLOATING_CHAT_HUB_PANEL_EXPERIMENT.name,
|
FLOATING_CHAT_HUB_PANEL_EXPERIMENT.name,
|
||||||
SURFACE_MCP_TO_NEW_CLOUD_USERS_EXPERIMENT.name,
|
SURFACE_MCP_TO_NEW_CLOUD_USERS_EXPERIMENT.name,
|
||||||
CANVAS_NODES_GROUPING_EXPERIMENT.name,
|
CANVAS_NODES_GROUPING_EXPERIMENT.name,
|
||||||
|
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT.name,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,517 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, reactive, ref, onMounted, onUnmounted, watch, type Component } from 'vue';
|
||||||
|
import { useElementSize } from '@vueuse/core';
|
||||||
|
import type {
|
||||||
|
PreviewWorkflow,
|
||||||
|
PreviewWorkflowConnection,
|
||||||
|
PreviewVisualizationType,
|
||||||
|
PreviewOutputVisualization,
|
||||||
|
} from '../workflows/types';
|
||||||
|
import WorkflowPreviewNode from './WorkflowPreviewNode.vue';
|
||||||
|
import SlackMessageVisualization from './visualizations/SlackMessageVisualization.vue';
|
||||||
|
import SalesforceCardVisualization from './visualizations/SalesforceCardVisualization.vue';
|
||||||
|
import InvoiceSpreadsheetVisualization from './visualizations/InvoiceSpreadsheetVisualization.vue';
|
||||||
|
import WhatsAppChatVisualization from './visualizations/WhatsAppChatVisualization.vue';
|
||||||
|
|
||||||
|
const NODE_HALF_WIDTH = 48;
|
||||||
|
const EDGE_CURVE_OFFSET = 60;
|
||||||
|
const PADDING = 80;
|
||||||
|
const CANVAS_HEIGHT = 420;
|
||||||
|
const NODE_LABEL_OFFSET = 13;
|
||||||
|
|
||||||
|
const NODE_RUNNING_DURATION_MS = 250;
|
||||||
|
|
||||||
|
export type NodeAnimationState = 'idle' | 'running' | 'success';
|
||||||
|
export type AnimationPhase = 'idle' | 'input' | 'nodes' | 'output' | 'done';
|
||||||
|
|
||||||
|
const visualizationComponents: Record<PreviewVisualizationType, Component> = {
|
||||||
|
'slack-message': SlackMessageVisualization,
|
||||||
|
'salesforce-card': SalesforceCardVisualization,
|
||||||
|
'invoice-spreadsheet': InvoiceSpreadsheetVisualization,
|
||||||
|
'whatsapp-chat': WhatsAppChatVisualization,
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
workflow: PreviewWorkflow;
|
||||||
|
animating?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ animating: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const canvasRef = ref<HTMLElement | null>(null);
|
||||||
|
const { width: canvasRenderedWidth } = useElementSize(canvasRef);
|
||||||
|
|
||||||
|
const animationPhase = ref<AnimationPhase>('idle');
|
||||||
|
const nodeStates = reactive<Record<string, NodeAnimationState>>({});
|
||||||
|
|
||||||
|
const hasInputViz = computed(() => !!props.workflow.inputVisualization);
|
||||||
|
const hasOutputViz = computed(() => !!props.workflow.outputVisualization);
|
||||||
|
|
||||||
|
const outputVizItems = computed((): PreviewOutputVisualization[] => {
|
||||||
|
const viz = props.workflow.outputVisualization;
|
||||||
|
if (!viz) return [];
|
||||||
|
if (Array.isArray(viz)) return viz;
|
||||||
|
const last = lastNode.value;
|
||||||
|
if (!last) return [];
|
||||||
|
return [{ type: viz.type, props: viz.props, targetNodeId: last.id }];
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputVizComponent = computed(() =>
|
||||||
|
props.workflow.inputVisualization
|
||||||
|
? visualizationComponents[props.workflow.inputVisualization.type]
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const executionSteps = computed(() => {
|
||||||
|
const { nodes, connections } = props.workflow;
|
||||||
|
const incomingMap = new Map<string, Set<string>>();
|
||||||
|
for (const node of nodes) {
|
||||||
|
incomingMap.set(node.id, new Set());
|
||||||
|
}
|
||||||
|
for (const conn of connections) {
|
||||||
|
incomingMap.get(conn.target)?.add(conn.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const steps: string[][] = [];
|
||||||
|
|
||||||
|
while (visited.size < nodes.length) {
|
||||||
|
const ready = nodes
|
||||||
|
.filter((n) => !visited.has(n.id))
|
||||||
|
.filter((n) => {
|
||||||
|
const deps = incomingMap.get(n.id);
|
||||||
|
return !deps || [...deps].every((d) => visited.has(d));
|
||||||
|
})
|
||||||
|
.map((n) => n.id);
|
||||||
|
|
||||||
|
if (ready.length === 0) break;
|
||||||
|
steps.push(ready);
|
||||||
|
for (const id of ready) visited.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstNode = computed(() => {
|
||||||
|
const nodes = props.workflow.nodes;
|
||||||
|
if (nodes.length === 0) return undefined;
|
||||||
|
return nodes.reduce((min, n) => (n.position.x < min.position.x ? n : min), nodes[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastNode = computed(() => {
|
||||||
|
const nodes = props.workflow.nodes;
|
||||||
|
if (nodes.length === 0) return undefined;
|
||||||
|
return nodes.reduce((max, n) => (n.position.x > max.position.x ? n : max), nodes[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const triggerNodeIds = computed(() => {
|
||||||
|
const targets = new Set(props.workflow.connections.map((c) => c.target));
|
||||||
|
return new Set(props.workflow.nodes.filter((n) => !targets.has(n.id)).map((n) => n.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetStates() {
|
||||||
|
for (const node of props.workflow.nodes) {
|
||||||
|
nodeStates[node.id] = 'idle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let animationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
async function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
animationTimer = setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputCompletedCount = 0;
|
||||||
|
|
||||||
|
async function runNodeAnimation() {
|
||||||
|
for (const step of executionSteps.value) {
|
||||||
|
if (stopped) return;
|
||||||
|
for (const id of step) nodeStates[id] = 'running';
|
||||||
|
await sleep(NODE_RUNNING_DURATION_MS);
|
||||||
|
if (stopped) return;
|
||||||
|
for (const id of step) nodeStates[id] = 'success';
|
||||||
|
}
|
||||||
|
if (stopped) return;
|
||||||
|
if (hasOutputViz.value) {
|
||||||
|
outputCompletedCount = 0;
|
||||||
|
animationPhase.value = 'output';
|
||||||
|
} else {
|
||||||
|
animationPhase.value = 'done';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAnimation() {
|
||||||
|
stopped = false;
|
||||||
|
resetStates();
|
||||||
|
if (hasInputViz.value) {
|
||||||
|
animationPhase.value = 'input';
|
||||||
|
} else {
|
||||||
|
animationPhase.value = 'nodes';
|
||||||
|
void runNodeAnimation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAnimation() {
|
||||||
|
stopped = true;
|
||||||
|
if (animationTimer) {
|
||||||
|
clearTimeout(animationTimer);
|
||||||
|
animationTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputComplete() {
|
||||||
|
if (stopped) return;
|
||||||
|
animationPhase.value = 'nodes';
|
||||||
|
void runNodeAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOutputComplete() {
|
||||||
|
if (stopped) return;
|
||||||
|
outputCompletedCount++;
|
||||||
|
if (outputCompletedCount >= outputVizItems.value.length) {
|
||||||
|
animationPhase.value = 'done';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.animating,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
startAnimation();
|
||||||
|
} else {
|
||||||
|
stopAnimation();
|
||||||
|
resetStates();
|
||||||
|
animationPhase.value = 'idle';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resetStates();
|
||||||
|
if (props.animating) {
|
||||||
|
startAnimation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(stopAnimation);
|
||||||
|
|
||||||
|
// CRM icons cycling logic (score-my-leads example)
|
||||||
|
const crmCycleIndex = ref(0);
|
||||||
|
const crmCycleVisible = ref(false);
|
||||||
|
let crmCycleStartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let crmCycleInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const hasCrmCycle = computed(() => !!props.workflow.crmCycle);
|
||||||
|
const crmVariants = computed(() => props.workflow.crmCycle?.variants ?? []);
|
||||||
|
const crmCurrentVariant = computed(() => crmVariants.value[crmCycleIndex.value]);
|
||||||
|
const crmCycleNodeIds = computed(() => new Set(props.workflow.crmCycle?.nodeIds ?? []));
|
||||||
|
|
||||||
|
const CRM_INITIAL_DELAY_MS = 500;
|
||||||
|
const CRM_INTERVAL_MS = 1400;
|
||||||
|
|
||||||
|
function startCrmCycle() {
|
||||||
|
if (!hasCrmCycle.value) return;
|
||||||
|
crmCycleVisible.value = true;
|
||||||
|
crmCycleIndex.value = 0;
|
||||||
|
const interval = props.workflow.crmCycle?.intervalMs ?? CRM_INTERVAL_MS;
|
||||||
|
|
||||||
|
crmCycleStartTimer = setTimeout(() => {
|
||||||
|
crmCycleIndex.value = (crmCycleIndex.value + 1) % crmVariants.value.length;
|
||||||
|
crmCycleInterval = setInterval(() => {
|
||||||
|
crmCycleIndex.value = (crmCycleIndex.value + 1) % crmVariants.value.length;
|
||||||
|
}, interval);
|
||||||
|
}, CRM_INITIAL_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCrmCycle() {
|
||||||
|
crmCycleVisible.value = false;
|
||||||
|
if (crmCycleStartTimer) {
|
||||||
|
clearTimeout(crmCycleStartTimer);
|
||||||
|
crmCycleStartTimer = null;
|
||||||
|
}
|
||||||
|
if (crmCycleInterval) {
|
||||||
|
clearInterval(crmCycleInterval);
|
||||||
|
crmCycleInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(animationPhase, (phase) => {
|
||||||
|
if (phase === 'done' && hasCrmCycle.value) {
|
||||||
|
startCrmCycle();
|
||||||
|
} else {
|
||||||
|
stopCrmCycle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(stopCrmCycle);
|
||||||
|
|
||||||
|
const inputVizIcon = computed(() => {
|
||||||
|
if (crmCycleVisible.value && crmCurrentVariant.value) {
|
||||||
|
return crmCurrentVariant.value.icon.src;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const bounds = computed(() => {
|
||||||
|
const nodes = props.workflow.nodes;
|
||||||
|
if (nodes.length === 0) return { minX: 0, minY: 0, width: 400, height: 200 };
|
||||||
|
|
||||||
|
const xs = nodes.map((n) => n.position.x);
|
||||||
|
const ys = nodes.map((n) => n.position.y);
|
||||||
|
const minX = Math.min(...xs) - PADDING;
|
||||||
|
const minY = Math.min(...ys) - PADDING;
|
||||||
|
const maxX = Math.max(...xs) + PADDING;
|
||||||
|
const maxY = Math.max(...ys) + PADDING;
|
||||||
|
|
||||||
|
return { minX, minY, width: maxX - minX, height: maxY - minY };
|
||||||
|
});
|
||||||
|
|
||||||
|
const CANVAS_WIDTH = 1600;
|
||||||
|
const VIZ_MARGIN = 160;
|
||||||
|
const VIZ_GAP = 28;
|
||||||
|
const CANVAS_INNER_PADDING = 60;
|
||||||
|
|
||||||
|
const scale = computed(() => {
|
||||||
|
const { width, height } = bounds.value;
|
||||||
|
let availableWidth = CANVAS_WIDTH - 2 * CANVAS_INNER_PADDING;
|
||||||
|
if (hasInputViz.value) availableWidth -= VIZ_MARGIN;
|
||||||
|
if (hasOutputViz.value) availableWidth -= VIZ_MARGIN;
|
||||||
|
const scaleX = availableWidth / width;
|
||||||
|
const scaleY = (CANVAS_HEIGHT - 2 * CANVAS_INNER_PADDING) / height;
|
||||||
|
return Math.min(scaleX, scaleY, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectiveCanvasWidth = computed(() =>
|
||||||
|
canvasRenderedWidth.value > 0 ? canvasRenderedWidth.value : CANVAS_WIDTH,
|
||||||
|
);
|
||||||
|
|
||||||
|
const containerStyle = computed(() => {
|
||||||
|
const { width, height } = bounds.value;
|
||||||
|
const s = scale.value;
|
||||||
|
const scaledWidth = width * s;
|
||||||
|
const scaledHeight = height * s;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: `${width}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
transform: `scale(${s})`,
|
||||||
|
transformOrigin: 'top left',
|
||||||
|
left: `${(effectiveCanvasWidth.value - scaledWidth) / 2}px`,
|
||||||
|
top: `${(CANVAS_HEIGHT - scaledHeight) / 2}px`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewBox = computed(
|
||||||
|
() => `${bounds.value.minX} ${bounds.value.minY} ${bounds.value.width} ${bounds.value.height}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const canvasMarginLeft = computed(() => {
|
||||||
|
const s = scale.value;
|
||||||
|
const scaledWidth = bounds.value.width * s;
|
||||||
|
return (effectiveCanvasWidth.value - scaledWidth) / 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvasMarginTop = computed(() => {
|
||||||
|
const s = scale.value;
|
||||||
|
const scaledHeight = bounds.value.height * s;
|
||||||
|
return (CANVAS_HEIGHT - scaledHeight) / 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toScreenX(workflowX: number): number {
|
||||||
|
return (workflowX - bounds.value.minX) * scale.value + canvasMarginLeft.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toScreenY(workflowY: number): number {
|
||||||
|
return (workflowY - bounds.value.minY) * scale.value + canvasMarginTop.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputSlotStyle = computed(() => {
|
||||||
|
if (!firstNode.value) return {};
|
||||||
|
const nodeScreenX = toScreenX(firstNode.value.position.x - NODE_HALF_WIDTH);
|
||||||
|
const nodeScreenY = toScreenY(firstNode.value.position.y - NODE_LABEL_OFFSET);
|
||||||
|
return {
|
||||||
|
left: `${nodeScreenX - VIZ_GAP}px`,
|
||||||
|
top: `${nodeScreenY}px`,
|
||||||
|
transform: 'translateX(-100%) translateY(-50%)',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const OUTPUT_VIZ_HEIGHT = 80;
|
||||||
|
const OUTPUT_VIZ_GAP = 16;
|
||||||
|
|
||||||
|
const outputSlotStyles = computed(() => {
|
||||||
|
const items = outputVizItems.value;
|
||||||
|
if (items.length === 0) return [];
|
||||||
|
|
||||||
|
const positions = items.map((item) => {
|
||||||
|
const node = props.workflow.nodes.find((n) => n.id === item.targetNodeId);
|
||||||
|
const targetNode = node ?? lastNode.value;
|
||||||
|
if (!targetNode) return { x: 0, y: 0 };
|
||||||
|
return {
|
||||||
|
x: toScreenX(targetNode.position.x + NODE_HALF_WIDTH),
|
||||||
|
y: toScreenY(targetNode.position.y - NODE_LABEL_OFFSET),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length > 1) {
|
||||||
|
const sorted = positions.map((p, i) => ({ idx: i, y: p.y })).sort((a, b) => a.y - b.y);
|
||||||
|
const minSpacing = OUTPUT_VIZ_HEIGHT + OUTPUT_VIZ_GAP;
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
const prev = sorted[i - 1];
|
||||||
|
const curr = sorted[i];
|
||||||
|
if (curr.y - prev.y < minSpacing) {
|
||||||
|
positions[curr.idx].y = positions[prev.idx].y + minSpacing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMulti = items.length > 1;
|
||||||
|
return positions.map((pos) => ({
|
||||||
|
left: `${pos.x + VIZ_GAP}px`,
|
||||||
|
top: `${pos.y}px`,
|
||||||
|
...(isMulti ? {} : { transform: 'translateY(-50%)' }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
function getEdgePath(connection: PreviewWorkflowConnection): string {
|
||||||
|
const sourceNode = props.workflow.nodes.find((n) => n.id === connection.source);
|
||||||
|
const targetNode = props.workflow.nodes.find((n) => n.id === connection.target);
|
||||||
|
if (!sourceNode || !targetNode) return '';
|
||||||
|
|
||||||
|
const sx = sourceNode.position.x + NODE_HALF_WIDTH;
|
||||||
|
const sy = sourceNode.position.y - NODE_LABEL_OFFSET;
|
||||||
|
const tx = targetNode.position.x - NODE_HALF_WIDTH;
|
||||||
|
const ty = targetNode.position.y - NODE_LABEL_OFFSET;
|
||||||
|
|
||||||
|
const dist = Math.abs(tx - sx);
|
||||||
|
const cx = Math.min(EDGE_CURVE_OFFSET, dist * 0.4);
|
||||||
|
return `M ${sx} ${sy} C ${sx + cx} ${sy}, ${tx - cx} ${ty}, ${tx} ${ty}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEdgeSuccess(connection: PreviewWorkflowConnection): boolean {
|
||||||
|
return nodeStates[connection.target] === 'success';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="canvasRef" :class="$style.canvas">
|
||||||
|
<div :class="$style.viewport" :style="containerStyle">
|
||||||
|
<svg :class="$style.edges" :viewBox="viewBox">
|
||||||
|
<path
|
||||||
|
v-for="(conn, idx) in props.workflow.connections"
|
||||||
|
:key="`edge-${idx}`"
|
||||||
|
:d="getEdgePath(conn)"
|
||||||
|
:class="[$style.edge, isEdgeSuccess(conn) && $style.edgeSuccess]"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div :class="$style.nodesLayer">
|
||||||
|
<WorkflowPreviewNode
|
||||||
|
v-for="node in props.workflow.nodes"
|
||||||
|
:key="node.id"
|
||||||
|
:node="node"
|
||||||
|
:state="nodeStates[node.id] ?? 'idle'"
|
||||||
|
:trigger="triggerNodeIds.has(node.id)"
|
||||||
|
:offset-x="bounds.minX"
|
||||||
|
:offset-y="bounds.minY"
|
||||||
|
:icon-override="
|
||||||
|
crmCycleNodeIds.has(node.id) && crmCycleVisible ? crmCurrentVariant?.icon : undefined
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="inputVizComponent" :class="$style.vizSlot" :style="inputSlotStyle">
|
||||||
|
<component
|
||||||
|
:is="inputVizComponent"
|
||||||
|
:active="animationPhase !== 'idle'"
|
||||||
|
v-bind="props.workflow.inputVisualization?.props"
|
||||||
|
:icon-override="inputVizIcon"
|
||||||
|
slide-from="left"
|
||||||
|
@complete="handleInputComplete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(outputViz, idx) in outputVizItems"
|
||||||
|
:key="`output-viz-${idx}`"
|
||||||
|
:class="[$style.vizSlot, outputVizItems.length > 1 && $style.vizSlotCompact]"
|
||||||
|
:style="outputSlotStyles[idx]"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="visualizationComponents[outputViz.type]"
|
||||||
|
:active="animationPhase === 'output' || animationPhase === 'done'"
|
||||||
|
v-bind="outputViz.props"
|
||||||
|
:icon-override="
|
||||||
|
outputViz.type === 'salesforce-card' && crmCycleVisible ? inputVizIcon : undefined
|
||||||
|
"
|
||||||
|
@complete="handleOutputComplete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.canvas {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1600px;
|
||||||
|
height: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--canvas--color--background);
|
||||||
|
background-image: radial-gradient(
|
||||||
|
oklch(from var(--canvas--dot--color) l c h / 0.5) 1px,
|
||||||
|
transparent 1px
|
||||||
|
);
|
||||||
|
background-size: 16px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius--xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edges {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--color--foreground--shade-1);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edgeSuccess {
|
||||||
|
stroke: var(--color--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodesLayer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vizSlot {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vizSlotCompact {
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transform-origin: left center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { N8nIcon } from '@n8n/design-system';
|
||||||
|
import type { IconName } from '@n8n/design-system';
|
||||||
|
import type { PreviewWorkflowNode, PreviewWorkflowNodeIcon } from '../workflows/types';
|
||||||
|
import type { NodeAnimationState } from './WorkflowPreviewCanvas.vue';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
node: PreviewWorkflowNode;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
state?: NodeAnimationState;
|
||||||
|
trigger?: boolean;
|
||||||
|
iconOverride?: PreviewWorkflowNodeIcon;
|
||||||
|
}>(),
|
||||||
|
{ state: 'idle', trigger: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const style = computed(() => ({
|
||||||
|
left: `${props.node.position.x - props.offsetX}px`,
|
||||||
|
top: `${props.node.position.y - props.offsetY}px`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const iconWrapperStyle = computed(() => {
|
||||||
|
if (props.node.iconColor) {
|
||||||
|
return { color: `var(--node--icon--color--${props.node.iconColor})` };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeIcon = computed(() => props.iconOverride ?? props.node.icon);
|
||||||
|
|
||||||
|
const iconName = computed(() =>
|
||||||
|
activeIcon.value.type === 'icon' ? (activeIcon.value.name as IconName) : undefined,
|
||||||
|
);
|
||||||
|
const iconSrc = computed(() =>
|
||||||
|
activeIcon.value.type === 'file' ? activeIcon.value.src : undefined,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
$style.node,
|
||||||
|
props.trigger && $style.trigger,
|
||||||
|
props.state === 'running' && $style.running,
|
||||||
|
props.state === 'success' && $style.success,
|
||||||
|
]"
|
||||||
|
:style="style"
|
||||||
|
>
|
||||||
|
<div :class="$style.iconWrapper" :style="iconWrapperStyle">
|
||||||
|
<N8nIcon v-if="iconName" :icon="iconName" :size="48" />
|
||||||
|
<Transition v-else-if="iconSrc" :name="$style.swipe" mode="out-in">
|
||||||
|
<img :key="iconSrc" :src="iconSrc" :class="$style.iconImage" />
|
||||||
|
</Transition>
|
||||||
|
<span v-else :class="$style.iconFallback">{{ props.node.label.charAt(0) }}</span>
|
||||||
|
</div>
|
||||||
|
<span :class="$style.label">{{ props.node.label }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.node {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconWrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: var(--radius--lg);
|
||||||
|
background: var(--node--color--background, var(--color--background--light-3));
|
||||||
|
border: 1.5px solid
|
||||||
|
light-dark(
|
||||||
|
oklch(from var(--color--neutral-black) l c h / 0.1),
|
||||||
|
oklch(from var(--color--neutral-white) l c h / 0.15)
|
||||||
|
);
|
||||||
|
color: var(--node--icon--color, var(--color--foreground--shade-1));
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -3px;
|
||||||
|
border-radius: 10px;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0;
|
||||||
|
background: conic-gradient(
|
||||||
|
from var(--node--gradient-angle),
|
||||||
|
rgba(255, 109, 90, 1),
|
||||||
|
rgba(255, 109, 90, 1) 20%,
|
||||||
|
rgba(255, 109, 90, 0.2) 35%,
|
||||||
|
rgba(255, 109, 90, 0.2) 65%,
|
||||||
|
rgba(255, 109, 90, 1) 90%,
|
||||||
|
rgba(255, 109, 90, 1)
|
||||||
|
);
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger .iconWrapper {
|
||||||
|
border-radius: 36px var(--radius--lg) var(--radius--lg) 36px;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border-radius: 36px 10px 10px 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.running .iconWrapper {
|
||||||
|
border-color: transparent;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
opacity: 1;
|
||||||
|
animation: border-rotate 1.5s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success .iconWrapper {
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: var(--color--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconImage {
|
||||||
|
max-width: 48px;
|
||||||
|
max-height: 48px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconFallback {
|
||||||
|
font-size: var(--font-size--2xl);
|
||||||
|
font-weight: var(--font-weight--bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
margin-top: var(--spacing--2xs);
|
||||||
|
font-size: var(--font-size--md);
|
||||||
|
font-weight: var(--font-weight--medium);
|
||||||
|
line-height: var(--line-height--sm);
|
||||||
|
color: var(--color--text--base);
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 192px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe:global(-enter-active),
|
||||||
|
.swipe:global(-leave-active) {
|
||||||
|
transition:
|
||||||
|
transform 0.3s ease,
|
||||||
|
opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe:global(-enter-from) {
|
||||||
|
transform: translateX(16px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe:global(-leave-to) {
|
||||||
|
transform: translateX(-16px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property --node--gradient-angle {
|
||||||
|
syntax: '<angle>';
|
||||||
|
initial-value: 0deg;
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes border-rotate {
|
||||||
|
from {
|
||||||
|
--node--gradient-angle: 0deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
--node--gradient-angle: 360deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { N8nIcon } from '@n8n/design-system';
|
||||||
|
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||||
|
import { onUnmounted, ref } from 'vue';
|
||||||
|
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||||
|
import { type WorkflowPreviewSuggestion } from '../suggestions';
|
||||||
|
|
||||||
|
const PREVIEW_HOVER_DELAY_MS = 300;
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
suggestions: readonly WorkflowPreviewSuggestion[];
|
||||||
|
disabled: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
interface SubmitSuggestionPayload {
|
||||||
|
promptKey: BaseTextKey;
|
||||||
|
suggestionId: string;
|
||||||
|
suggestionKind: 'prompt';
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'preview-change': [promptKey: BaseTextKey | null];
|
||||||
|
'submit-suggestion': [payload: SubmitSuggestionPayload];
|
||||||
|
'workflow-preview': [workflowFile: string | null];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
const activePreview = ref<string | null>(null);
|
||||||
|
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const hoverStartTimes = new Map<string, number>();
|
||||||
|
|
||||||
|
function clearHoverTimer() {
|
||||||
|
if (!hoverTimer) return;
|
||||||
|
clearTimeout(hoverTimer);
|
||||||
|
hoverTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackHoverEnd(suggestionId: string) {
|
||||||
|
const startTime = hoverStartTimes.get(suggestionId);
|
||||||
|
if (startTime === undefined) return;
|
||||||
|
hoverStartTimes.delete(suggestionId);
|
||||||
|
|
||||||
|
telemetry.track('AI Assistant suggestion button hovered', {
|
||||||
|
suggestion_id: suggestionId,
|
||||||
|
seconds: Math.floor((Date.now() - startTime) / 1000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPreview() {
|
||||||
|
clearHoverTimer();
|
||||||
|
for (const id of hoverStartTimes.keys()) {
|
||||||
|
trackHoverEnd(id);
|
||||||
|
}
|
||||||
|
activePreview.value = null;
|
||||||
|
emit('preview-change', null);
|
||||||
|
emit('workflow-preview', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSuggestionEnter(suggestion: WorkflowPreviewSuggestion) {
|
||||||
|
if (props.disabled) return;
|
||||||
|
|
||||||
|
hoverStartTimes.set(suggestion.id, Date.now());
|
||||||
|
clearHoverTimer();
|
||||||
|
hoverTimer = setTimeout(() => {
|
||||||
|
hoverTimer = null;
|
||||||
|
activePreview.value = suggestion.id;
|
||||||
|
emit('preview-change', suggestion.promptKey);
|
||||||
|
emit('workflow-preview', suggestion.workflowFile);
|
||||||
|
}, PREVIEW_HOVER_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSuggestionFocus(suggestion: WorkflowPreviewSuggestion) {
|
||||||
|
if (props.disabled) return;
|
||||||
|
clearHoverTimer();
|
||||||
|
activePreview.value = suggestion.id;
|
||||||
|
emit('preview-change', suggestion.promptKey);
|
||||||
|
emit('workflow-preview', suggestion.workflowFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSuggestionClick(suggestion: WorkflowPreviewSuggestion) {
|
||||||
|
if (props.disabled) return;
|
||||||
|
|
||||||
|
const position = props.suggestions.indexOf(suggestion) + 1;
|
||||||
|
telemetry.track('AI Assistant suggestion button clicked', {
|
||||||
|
suggestion_id: suggestion.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearPreview();
|
||||||
|
emit('submit-suggestion', {
|
||||||
|
promptKey: suggestion.promptKey,
|
||||||
|
suggestionId: suggestion.id,
|
||||||
|
suggestionKind: 'prompt',
|
||||||
|
position,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(clearPreview);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.suggestions" data-test-id="instance-ai-workflow-preview-suggestions">
|
||||||
|
<div :class="$style.suggestionRow">
|
||||||
|
<button
|
||||||
|
v-for="(suggestion, index) in props.suggestions"
|
||||||
|
:key="suggestion.id"
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
$style.suggestionButton,
|
||||||
|
activePreview === suggestion.id && $style.suggestionButtonActive,
|
||||||
|
]"
|
||||||
|
:style="{ animationDelay: `${index * 50}ms` }"
|
||||||
|
:data-test-id="`instance-ai-suggestion-${suggestion.id}`"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
@click="handleSuggestionClick(suggestion)"
|
||||||
|
@mouseenter="handleSuggestionEnter(suggestion)"
|
||||||
|
@mouseleave="clearPreview"
|
||||||
|
@focus="handleSuggestionFocus(suggestion)"
|
||||||
|
@blur="clearPreview"
|
||||||
|
>
|
||||||
|
<N8nIcon :icon="suggestion.icon" :size="12" :class="$style.suggestionIcon" />
|
||||||
|
<span>{{ i18n.baseText(suggestion.labelKey) }}</span>
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="https://n8n.io/workflows/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:class="$style.seeAllLink"
|
||||||
|
>
|
||||||
|
<span>{{
|
||||||
|
i18n.baseText(
|
||||||
|
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.seeAll' as BaseTextKey,
|
||||||
|
)
|
||||||
|
}}</span>
|
||||||
|
<N8nIcon icon="arrow-right" :size="12" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
@use '@/features/ai/shared/styles/prompt-suggestion-buttons' as promptSuggestions;
|
||||||
|
|
||||||
|
.suggestions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing--2xs);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionButton {
|
||||||
|
@include promptSuggestions.prompt-suggestion-button;
|
||||||
|
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionButtonActive {
|
||||||
|
@include promptSuggestions.prompt-suggestion-button-active;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionIcon {
|
||||||
|
@include promptSuggestions.prompt-suggestion-icon;
|
||||||
|
|
||||||
|
.suggestionButton:hover &,
|
||||||
|
.suggestionButton:focus-visible & {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.seeAllLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: var(--font-size--2xs);
|
||||||
|
color: var(--color--text--tint-1);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, watch, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
const APPEAR_DELAY_MS = 200;
|
||||||
|
const ROW_DELAY_MS = 450;
|
||||||
|
const CHECK_DELAY_MS = ROW_DELAY_MS + 350;
|
||||||
|
const COMPLETE_DELAY_MS = CHECK_DELAY_MS + 650;
|
||||||
|
|
||||||
|
const props = defineProps<{ active: boolean }>();
|
||||||
|
const emit = defineEmits<{ complete: [] }>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const rowVisible = ref(false);
|
||||||
|
const checkVisible = ref(false);
|
||||||
|
|
||||||
|
let timers: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
|
|
||||||
|
function clearTimers() {
|
||||||
|
for (const t of timers) clearTimeout(t);
|
||||||
|
timers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAnimation() {
|
||||||
|
visible.value = false;
|
||||||
|
rowVisible.value = false;
|
||||||
|
checkVisible.value = false;
|
||||||
|
|
||||||
|
const base = APPEAR_DELAY_MS;
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
visible.value = true;
|
||||||
|
}, base),
|
||||||
|
);
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
rowVisible.value = true;
|
||||||
|
}, base + ROW_DELAY_MS),
|
||||||
|
);
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
checkVisible.value = true;
|
||||||
|
}, base + CHECK_DELAY_MS),
|
||||||
|
);
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
emit('complete');
|
||||||
|
}, base + COMPLETE_DELAY_MS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
runAnimation();
|
||||||
|
} else {
|
||||||
|
clearTimers();
|
||||||
|
visible.value = false;
|
||||||
|
rowVisible.value = false;
|
||||||
|
checkVisible.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onUnmounted(clearTimers);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="[$style.card, visible && $style.cardVisible]">
|
||||||
|
<div :class="$style.table">
|
||||||
|
<div :class="[$style.row, $style.rowHeader]">
|
||||||
|
<span :class="[$style.cell, $style.th]">Invoice</span>
|
||||||
|
<span :class="[$style.cell, $style.th]">Date</span>
|
||||||
|
<span :class="[$style.cell, $style.th]">Discrepancy</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="$style.row">
|
||||||
|
<span :class="$style.cell">INV-2024-045</span>
|
||||||
|
<span :class="$style.cell">May 14</span>
|
||||||
|
<span :class="[$style.cell, $style.tdMuted]">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="$style.row">
|
||||||
|
<span :class="$style.cell">INV-2024-046</span>
|
||||||
|
<span :class="$style.cell">May 21</span>
|
||||||
|
<span :class="[$style.cell, $style.tdMuted]">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="[$style.rowCollapse, rowVisible && $style.rowCollapseOpen]">
|
||||||
|
<div :class="[$style.row, $style.rowLast]">
|
||||||
|
<span :class="$style.cell">INV-2024-047</span>
|
||||||
|
<span :class="$style.cell">May 29</span>
|
||||||
|
<span :class="[$style.cell, $style.tdDiscrepancy]">
|
||||||
|
<svg
|
||||||
|
v-if="checkVisible"
|
||||||
|
viewBox="0 0 18 18"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
:class="$style.checkIcon"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2.5 9.5 L7 14 L15.5 4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
:class="$style.checkPath"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
$border: light-dark(
|
||||||
|
oklch(from var(--color--neutral-black) l c h / 0.1),
|
||||||
|
oklch(from var(--color--neutral-white) l c h / 0.12)
|
||||||
|
);
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 280px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--color--background--light-3);
|
||||||
|
border-radius: var(--radius--lg);
|
||||||
|
border: 1px solid $border;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(8px);
|
||||||
|
transition:
|
||||||
|
opacity 0.3s ease,
|
||||||
|
transform 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardVisible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1.2fr 1.3fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowHeader {
|
||||||
|
background: light-dark(
|
||||||
|
oklch(from var(--color--neutral-black) l c h / 0.04),
|
||||||
|
oklch(from var(--color--neutral-white) l c h / 0.06)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowCollapse {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowCollapseOpen {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowLast {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
font-size: var(--font-size--2xs);
|
||||||
|
line-height: 13px;
|
||||||
|
color: var(--color--text--base);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding: 6px 9px;
|
||||||
|
border-right: 1px solid $border;
|
||||||
|
border-bottom: 1px solid $border;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:last-of-type .cell {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowLast .cell {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.th {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: var(--font-weight--bold);
|
||||||
|
color: var(--color--text--tint-1);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdMuted {
|
||||||
|
color: var(--color--text--tint-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdDiscrepancy {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkIcon {
|
||||||
|
overflow: visible;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkPath {
|
||||||
|
stroke-dasharray: 20;
|
||||||
|
stroke-dashoffset: 20;
|
||||||
|
animation: draw-check 0.45s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes draw-check {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { SALESFORCE_ICON_SVG } from '../../workflows/score-my-leads';
|
||||||
|
import { computed, ref, watch, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
const APPEAR_DELAY_MS = 200;
|
||||||
|
const COMPLETE_DELAY_MS = 600;
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
active: boolean;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
slideFrom?: 'left' | 'right';
|
||||||
|
icon?: string;
|
||||||
|
iconOverride?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: 'New lead',
|
||||||
|
subtitle: 'John Doe',
|
||||||
|
slideFrom: 'right',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentIcon = computed(() => props.iconOverride ?? props.icon ?? SALESFORCE_ICON_SVG);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
complete: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
let timers: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
|
|
||||||
|
function clearTimers() {
|
||||||
|
for (const t of timers) clearTimeout(t);
|
||||||
|
timers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAnimation() {
|
||||||
|
visible.value = false;
|
||||||
|
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
visible.value = true;
|
||||||
|
}, APPEAR_DELAY_MS),
|
||||||
|
);
|
||||||
|
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
emit('complete');
|
||||||
|
}, APPEAR_DELAY_MS + COMPLETE_DELAY_MS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
(val) => {
|
||||||
|
if (val) runAnimation();
|
||||||
|
else clearTimers();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onUnmounted(clearTimers);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
$style.card,
|
||||||
|
visible && $style.cardVisible,
|
||||||
|
props.slideFrom === 'left' && $style.slideLeft,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Transition :name="$style.swipe" mode="out-in">
|
||||||
|
<img :key="currentIcon" :class="$style.icon" :src="currentIcon" alt="" />
|
||||||
|
</Transition>
|
||||||
|
<div :class="$style.content">
|
||||||
|
<span :class="$style.title">{{ props.title }}</span>
|
||||||
|
<span :class="$style.subtitle">{{ props.subtitle }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.card {
|
||||||
|
width: 280px;
|
||||||
|
padding: var(--spacing--sm) var(--spacing--md);
|
||||||
|
background: var(--color--background--light-3);
|
||||||
|
border-radius: var(--radius--lg);
|
||||||
|
border: 1px solid
|
||||||
|
light-dark(
|
||||||
|
oklch(from var(--color--neutral-black) l c h / 0.08),
|
||||||
|
oklch(from var(--color--neutral-white) l c h / 0.12)
|
||||||
|
);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing--sm);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(8px);
|
||||||
|
transition:
|
||||||
|
opacity 0.3s ease,
|
||||||
|
transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slideLeft {
|
||||||
|
transform: translateX(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardVisible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe:global(-enter-active),
|
||||||
|
.swipe:global(-leave-active) {
|
||||||
|
transition:
|
||||||
|
transform 0.3s ease,
|
||||||
|
opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe:global(-enter-from) {
|
||||||
|
transform: translateX(16px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe:global(-leave-to) {
|
||||||
|
transform: translateX(-16px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing--2xs);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--font-size--sm);
|
||||||
|
font-weight: var(--font-weight--bold);
|
||||||
|
color: var(--color--text--base);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: var(--font-size--2xs);
|
||||||
|
color: var(--color--text--tint-1);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, watch, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
const APPEAR_DELAY_MS = 200;
|
||||||
|
const COMPLETE_DELAY_MS = 600;
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
active: boolean;
|
||||||
|
sender?: string;
|
||||||
|
message?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
sender: 'n8n Bot',
|
||||||
|
message: 'Urgent ticket: Login page broken',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
complete: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
let timers: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
|
|
||||||
|
function clearTimers() {
|
||||||
|
for (const t of timers) clearTimeout(t);
|
||||||
|
timers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAnimation() {
|
||||||
|
visible.value = false;
|
||||||
|
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
visible.value = true;
|
||||||
|
}, APPEAR_DELAY_MS),
|
||||||
|
);
|
||||||
|
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
emit('complete');
|
||||||
|
}, APPEAR_DELAY_MS + COMPLETE_DELAY_MS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
(val) => {
|
||||||
|
if (val) runAnimation();
|
||||||
|
else clearTimers();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onUnmounted(clearTimers);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="[$style.card, visible && $style.cardVisible]">
|
||||||
|
<div :class="$style.header">
|
||||||
|
<div :class="$style.avatar">
|
||||||
|
<img
|
||||||
|
:class="$style.avatarIcon"
|
||||||
|
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill='%23fff' fill-rule='evenodd' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 150.852 150.852'%3E%3Cuse xlink:href='%23a' x='.926' y='.926'/%3E%3Csymbol id='a' overflow='visible'%3E%3Cg stroke-width='1.852'%3E%3Cpath fill='%23e01e5a' stroke='%23e01e5a' d='M40.741 93.55c0-8.735 6.607-15.772 14.815-15.772s14.815 7.037 14.815 15.772v38.824c0 8.737-6.607 15.774-14.815 15.774s-14.815-7.037-14.815-15.772z'/%3E%3Cpath fill='%23ecb22d' stroke='%23ecb22d' d='M93.55 107.408c-8.735 0-15.772-6.607-15.772-14.815s7.037-14.815 15.772-14.815h38.826c8.735 0 15.772 6.607 15.772 14.815s-7.037 14.815-15.772 14.815z'/%3E%3Cpath fill='%232fb67c' stroke='%232fb67c' d='M77.778 15.772C77.778 7.037 84.385 0 92.593 0s14.815 7.037 14.815 15.772v38.826c0 8.735-6.607 15.772-14.815 15.772s-14.815-7.037-14.815-15.772z'/%3E%3Cpath fill='%2336c5f1' stroke='%2336c5f1' d='M15.772 70.371C7.037 70.371 0 63.763 0 55.556s7.037-14.815 15.772-14.815h38.826c8.735 0 15.772 6.607 15.772 14.815s-7.037 14.815-15.772 14.815z'/%3E%3Cg stroke-linejoin='miter'%3E%3Cpath fill='%23ecb22d' stroke='%23ecb22d' d='M77.778 133.333c0 8.208 6.607 14.815 14.815 14.815s14.815-6.607 14.815-14.815-6.607-14.815-14.815-14.815H77.778z'/%3E%3Cpath fill='%232fb67c' stroke='%232fb67c' d='M133.334 70.371h-14.815V55.556c0-8.207 6.607-14.815 14.815-14.815s14.815 6.607 14.815 14.815-6.607 14.815-14.815 14.815z'/%3E%3Cpath fill='%23e01e5a' stroke='%23e01e5a' d='M14.815 77.778H29.63v14.815c0 8.207-6.607 14.815-14.815 14.815S0 100.8 0 92.593s6.607-14.815 14.815-14.815z'/%3E%3Cpath fill='%2336c5f1' stroke='%2336c5f1' d='M70.371 14.815V29.63H55.556c-8.207 0-14.815-6.607-14.815-14.815S47.348 0 55.556 0s14.815 6.607 14.815 14.815z'/%3E%3C/g%3E%3C/g%3E%3C/symbol%3E%3C/svg%3E"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span :class="$style.sender">{{ props.sender }}</span>
|
||||||
|
</div>
|
||||||
|
<p :class="$style.message">{{ props.message }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.card {
|
||||||
|
width: 280px;
|
||||||
|
padding: var(--spacing--sm) var(--spacing--md);
|
||||||
|
background: var(--color--background--light-3);
|
||||||
|
border-radius: var(--radius--lg);
|
||||||
|
border: 1px solid
|
||||||
|
light-dark(
|
||||||
|
oklch(from var(--color--neutral-black) l c h / 0.08),
|
||||||
|
oklch(from var(--color--neutral-white) l c h / 0.12)
|
||||||
|
);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing--2xs);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(8px);
|
||||||
|
transition:
|
||||||
|
opacity 0.3s ease,
|
||||||
|
transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardVisible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing--2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color--neutral-700);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarIcon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender {
|
||||||
|
font-size: var(--font-size--sm);
|
||||||
|
font-weight: var(--font-weight--bold);
|
||||||
|
color: var(--color--text--base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size--sm);
|
||||||
|
color: var(--color--text--tint-1);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, watch, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
const APPEAR_DELAY_MS = 200;
|
||||||
|
const BUBBLE_DELAY_MS = 350;
|
||||||
|
const COMPLETE_DELAY_MS = BUBBLE_DELAY_MS + 700;
|
||||||
|
|
||||||
|
const WHATSAPP_ICON =
|
||||||
|
'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2260%22%20height%3D%2260%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22m4.868%2043.303%202.694-9.835a18.94%2018.94%200%200%201-2.535-9.489C5.032%2013.514%2013.548%205%2024.014%205a18.87%2018.87%200%200%201%2013.43%205.566A18.87%2018.87%200%200%201%2043%2023.994c-.004%2010.465-8.522%2018.98-18.986%2018.98h-.008a19%2019%200%200%201-9.073-2.311z%22/%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22M4.868%2043.803a.5.5%200%200%201-.482-.631l2.639-9.636a19.5%2019.5%200%200%201-2.497-9.556C4.532%2013.238%2013.273%204.5%2024.014%204.5a19.37%2019.37%200%200%201%2013.784%205.713A19.36%2019.36%200%200%201%2043.5%2023.994c-.004%2010.741-8.746%2019.48-19.486%2019.48a19.54%2019.54%200%200%201-9.144-2.277l-9.875%202.589a.5.5%200%200%201-.127.017%22/%3E%3Cpath%20fill%3D%22%23cfd8dc%22%20d%3D%22M24.014%205a18.87%2018.87%200%200%201%2013.43%205.566A18.87%2018.87%200%200%201%2043%2023.994c-.004%2010.465-8.522%2018.98-18.986%2018.98h-.008a19%2019%200%200%201-9.073-2.311l-10.065%202.64%202.694-9.835a18.94%2018.94%200%200%201-2.535-9.489C5.032%2013.514%2013.548%205%2024.014%205m0-1C12.998%204%204.032%2012.962%204.027%2023.979a20%2020%200%200%200%202.461%209.622L3.903%2043.04a.998.998%200%200%200%201.219%201.231l9.687-2.54a20%2020%200%200%200%209.197%202.244c11.024%200%2019.99-8.963%2019.995-19.98A19.86%2019.86%200%200%200%2038.153%209.86%2019.87%2019.87%200%200%200%2024.014%204%22/%3E%3Cpath%20fill%3D%22%2340c351%22%20d%3D%22M35.176%2012.832a15.67%2015.67%200%200%200-11.157-4.626c-8.704%200-15.783%207.076-15.787%2015.774a15.74%2015.74%200%200%200%202.413%208.396l.376.597-1.595%205.821%205.973-1.566.577.342a15.75%2015.75%200%200%200%208.032%202.199h.006c8.698%200%2015.777-7.077%2015.78-15.776a15.68%2015.68%200%200%200-4.618-11.161%22/%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22M19.268%2016.045c-.355-.79-.729-.806-1.068-.82-.277-.012-.593-.011-.909-.011s-.83.119-1.265.594-1.661%201.622-1.661%203.956%201.7%204.59%201.937%204.906%203.282%205.259%208.104%207.161c4.007%201.58%204.823%201.266%205.693%201.187s2.807-1.147%203.202-2.255.395-2.057.277-2.255c-.119-.198-.435-.316-.909-.554s-2.807-1.385-3.242-1.543-.751-.237-1.068.238c-.316.474-1.225%201.543-1.502%201.859s-.554.357-1.028.119-2.002-.738-3.815-2.354c-1.41-1.257-2.362-2.81-2.639-3.285-.277-.474-.03-.731.208-.968.213-.213.474-.554.712-.831.237-.277.316-.475.474-.791s.079-.594-.04-.831c-.117-.238-1.039-2.584-1.461-3.522%22/%3E%3C/svg%3E';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
active: boolean;
|
||||||
|
sender?: string;
|
||||||
|
message?: string;
|
||||||
|
isOutgoing?: boolean;
|
||||||
|
slideFrom?: 'left' | 'right';
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
sender: 'Customer',
|
||||||
|
message: 'How do I reset my password?',
|
||||||
|
isOutgoing: false,
|
||||||
|
slideFrom: 'right',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{ complete: [] }>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const bubbleVisible = ref(false);
|
||||||
|
|
||||||
|
let timers: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
|
|
||||||
|
function clearTimers() {
|
||||||
|
for (const t of timers) clearTimeout(t);
|
||||||
|
timers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAnimation() {
|
||||||
|
visible.value = false;
|
||||||
|
bubbleVisible.value = false;
|
||||||
|
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
visible.value = true;
|
||||||
|
}, APPEAR_DELAY_MS),
|
||||||
|
);
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
bubbleVisible.value = true;
|
||||||
|
}, APPEAR_DELAY_MS + BUBBLE_DELAY_MS),
|
||||||
|
);
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
emit('complete');
|
||||||
|
}, APPEAR_DELAY_MS + COMPLETE_DELAY_MS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
runAnimation();
|
||||||
|
} else {
|
||||||
|
clearTimers();
|
||||||
|
visible.value = false;
|
||||||
|
bubbleVisible.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onUnmounted(clearTimers);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="[$style.card, visible && $style.cardVisible, slideFrom === 'left' && $style.slideLeft]"
|
||||||
|
>
|
||||||
|
<div :class="$style.header">
|
||||||
|
<div :class="$style.avatar">
|
||||||
|
<img :src="WHATSAPP_ICON" :class="$style.avatarIcon" alt="" />
|
||||||
|
</div>
|
||||||
|
<span :class="$style.sender">{{ sender }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="bubbleVisible" :class="[$style.bubbleRow, isOutgoing && $style.bubbleRowOutgoing]">
|
||||||
|
<div :class="[$style.bubble, isOutgoing ? $style.bubbleOutgoing : $style.bubbleIncoming]">
|
||||||
|
<p :class="$style.bubbleText">{{ message }}</p>
|
||||||
|
<svg
|
||||||
|
v-if="isOutgoing"
|
||||||
|
:class="$style.readTicks"
|
||||||
|
width="18"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 18 12"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M1 6 L4.5 9.5 L10 2"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5 6 L8.5 9.5 L14 2"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.card {
|
||||||
|
width: 280px;
|
||||||
|
padding: var(--spacing--sm) var(--spacing--md);
|
||||||
|
background: var(--color--background--light-3);
|
||||||
|
border-radius: var(--radius--lg);
|
||||||
|
border: 1px solid
|
||||||
|
light-dark(
|
||||||
|
oklch(from var(--color--neutral-black) l c h / 0.08),
|
||||||
|
oklch(from var(--color--neutral-white) l c h / 0.12)
|
||||||
|
);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing--2xs);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(8px);
|
||||||
|
transition:
|
||||||
|
opacity 0.3s ease,
|
||||||
|
transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slideLeft {
|
||||||
|
transform: translateX(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardVisible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing--2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #25d366;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarIcon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender {
|
||||||
|
font-size: var(--font-size--sm);
|
||||||
|
font-weight: var(--font-weight--bold);
|
||||||
|
color: var(--color--text--base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubbleRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubbleRowOutgoing {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 85%;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
animation: bubble-pop 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubbleIncoming {
|
||||||
|
background: light-dark(#ebebeb, #3a3a3a);
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubbleOutgoing {
|
||||||
|
background: light-dark(#d9fdd3, #1a4a2a);
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubbleText {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size--xs);
|
||||||
|
color: var(--color--text--base);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readTicks {
|
||||||
|
color: #8696a0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bubble-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export { useInstanceAiWorkflowPreviewSuggestionsExperiment } from './useInstanceAiWorkflowPreviewSuggestionsExperiment';
|
||||||
|
export {
|
||||||
|
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS,
|
||||||
|
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION,
|
||||||
|
type WorkflowPreviewSuggestion,
|
||||||
|
} from './suggestions';
|
||||||
|
export { default as WorkflowPreviewSuggestions } from './components/WorkflowPreviewSuggestions.vue';
|
||||||
|
export { default as WorkflowPreviewCanvas } from './components/WorkflowPreviewCanvas.vue';
|
||||||
|
export { getPreviewWorkflow } from './workflows';
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import type { IconName } from '@n8n/design-system';
|
||||||
|
import type { BaseTextKey } from '@n8n/i18n';
|
||||||
|
import type { InstanceAiEmptyStatePromptSuggestion } from '@/features/ai/instanceAi/emptyStateSuggestions';
|
||||||
|
|
||||||
|
export interface WorkflowPreviewSuggestion extends InstanceAiEmptyStatePromptSuggestion {
|
||||||
|
workflowFile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION = 'v3-workflow-preview';
|
||||||
|
|
||||||
|
export const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS: readonly WorkflowPreviewSuggestion[] = [
|
||||||
|
{
|
||||||
|
type: 'prompt',
|
||||||
|
id: 'score-my-leads',
|
||||||
|
icon: 'badge-check' as IconName,
|
||||||
|
labelKey:
|
||||||
|
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.label' as BaseTextKey,
|
||||||
|
promptKey:
|
||||||
|
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.prompt' as BaseTextKey,
|
||||||
|
workflowFile: 'score-my-leads',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'prompt',
|
||||||
|
id: 'process-invoices',
|
||||||
|
icon: 'file-text' as IconName,
|
||||||
|
labelKey:
|
||||||
|
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.label' as BaseTextKey,
|
||||||
|
promptKey:
|
||||||
|
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.prompt' as BaseTextKey,
|
||||||
|
workflowFile: 'process-invoices',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'prompt',
|
||||||
|
id: 'whatsapp-support',
|
||||||
|
icon: 'message-circle' as IconName,
|
||||||
|
labelKey:
|
||||||
|
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.label' as BaseTextKey,
|
||||||
|
promptKey:
|
||||||
|
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.prompt' as BaseTextKey,
|
||||||
|
workflowFile: 'whatsapp-support',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'prompt',
|
||||||
|
id: 'schedule-social-posts',
|
||||||
|
icon: 'calendar' as IconName,
|
||||||
|
labelKey:
|
||||||
|
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.label' as BaseTextKey,
|
||||||
|
promptKey:
|
||||||
|
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.prompt' as BaseTextKey,
|
||||||
|
workflowFile: 'schedule-social-posts',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT } from '@/app/constants/experiments';
|
||||||
|
import { usePostHog } from '@/app/stores/posthog.store';
|
||||||
|
|
||||||
|
export function useInstanceAiWorkflowPreviewSuggestionsExperiment() {
|
||||||
|
const posthogStore = usePostHog();
|
||||||
|
|
||||||
|
const isFeatureEnabled = computed(
|
||||||
|
() =>
|
||||||
|
posthogStore.getVariant(INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT.name) ===
|
||||||
|
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT.variant,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { isFeatureEnabled };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { PreviewWorkflow } from './types';
|
||||||
|
import { scoreMyLeadsWorkflow } from './score-my-leads';
|
||||||
|
import { processInvoicesWorkflow } from './process-invoices';
|
||||||
|
import { whatsappSupportWorkflow } from './whatsapp-support';
|
||||||
|
import { scheduleSocialPostsWorkflow } from './schedule-social-posts';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
PreviewWorkflow,
|
||||||
|
PreviewWorkflowNode,
|
||||||
|
PreviewWorkflowConnection,
|
||||||
|
PreviewVisualization,
|
||||||
|
PreviewVisualizationType,
|
||||||
|
PreviewOutputVisualization,
|
||||||
|
CrmCycleConfig,
|
||||||
|
CrmCycleVariant,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const workflowRegistry: Record<string, PreviewWorkflow> = {
|
||||||
|
'score-my-leads': scoreMyLeadsWorkflow,
|
||||||
|
'process-invoices': processInvoicesWorkflow,
|
||||||
|
'whatsapp-support': whatsappSupportWorkflow,
|
||||||
|
'schedule-social-posts': scheduleSocialPostsWorkflow,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPreviewWorkflow(workflowFile: string): PreviewWorkflow | undefined {
|
||||||
|
return workflowRegistry[workflowFile];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import type { PreviewWorkflow } from './types';
|
||||||
|
|
||||||
|
const GMAIL_ICON_SVG =
|
||||||
|
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%22256%22%20height=%22193%22%20preserveAspectRatio=%22xMidYMid%22%3E%3Cpath%20fill=%22%234285F4%22%20d=%22M58.182%20192.05V93.14L27.507%2065.077%200%2049.504v125.091c0%209.658%207.825%2017.455%2017.455%2017.455z%22/%3E%3Cpath%20fill=%22%2334A853%22%20d=%22M197.818%20192.05h40.727c9.659%200%2017.455-7.826%2017.455-17.455V49.505l-31.156%2017.837-27.026%2025.798z%22/%3E%3Cpath%20fill=%22%23EA4335%22%20d=%22m58.182%2093.14-4.174-38.647%204.174-36.989L128%2069.868l69.818-52.364%204.67%2034.992-4.67%2040.644L128%20145.504z%22/%3E%3Cpath%20fill=%22%23FBBC04%22%20d=%22M197.818%2017.504V93.14L256%2049.504V26.231c0-21.585-24.64-33.89-41.89-20.945z%22/%3E%3Cpath%20fill=%22%23C5221F%22%20d=%22m0%2049.504%2026.759%2020.07L58.182%2093.14V17.504L41.89%205.286C24.61-7.66%200%204.646%200%2026.23z%22/%3E%3C/svg%3E';
|
||||||
|
|
||||||
|
const ANTHROPIC_ICON_SVG =
|
||||||
|
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%2246%22%20height=%2232%22%20fill=%22none%22%3E%3Cpath%20fill=%22%237D7D87%22%20d=%22M32.73%200h-6.945L38.45%2032h6.945zM12.665%200%200%2032h7.082l2.59-6.72h13.25l2.59%206.72h7.082L19.929%200zm-.702%2019.337%204.334-11.246%204.334%2011.246z%22/%3E%3C/svg%3E';
|
||||||
|
|
||||||
|
const GOOGLE_SHEETS_ICON_SVG =
|
||||||
|
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%2260%22%20height=%2260%22%3E%3Cg%20fill=%22none%22%20fill-rule=%22evenodd%22%20stroke-linecap=%22round%22%20stroke-linejoin=%22round%22%3E%3Cpath%20fill=%22%2328B446%22%20d=%22M35.69%201%2052%2017.225v39.087a3.67%203.67%200%200%201-1.084%202.61A3.7%203.7%200%200%201%2048.293%2060H12.707a3.7%203.7%200%200%201-2.623-1.078A3.67%203.67%200%200%201%209%2056.312V4.688a3.67%203.67%200%200%201%201.084-2.61A3.7%203.7%200%200%201%2012.707%201z%22/%3E%3Cpath%20fill=%22%236ACE7C%22%20d=%22M35.69%201%2052%2017.225H39.397c-2.054%200-3.707-1.829-3.707-3.872z%22/%3E%3Cpath%20fill=%22%23219B38%22%20d=%22M39.211%2017.225%2052%2022.48v-5.255z%22/%3E%3Cpath%20fill=%22%23FFF%22%20d=%22M20.12%2031.975c0-.817.662-1.475%201.483-1.475h17.794c.821%200%201.482.658%201.482%201.475v15.487c0%20.818-.661%201.475-1.482%201.475H21.603a1.476%201.476%200%200%201-1.482-1.474V31.974zm2.225%201.475h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672zm9.638-10.325h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672z%22/%3E%3Cpath%20fill=%22%2328B446%22%20d=%22M34.69%200%2051%2016.225v39.087a3.67%203.67%200%200%201-1.084%202.61A3.7%203.7%200%200%201%2047.293%2059H11.707a3.7%203.7%200%200%201-2.623-1.078A3.67%203.67%200%200%201%208%2055.312V3.688a3.67%203.67%200%200%201%201.084-2.61A3.7%203.7%200%200%201%2011.707%200z%22/%3E%3Cpath%20fill=%22%236ACE7C%22%20d=%22M34.69%200%2051%2016.225H38.397c-2.054%200-3.707-1.829-3.707-3.872z%22/%3E%3Cpath%20fill=%22%23219B38%22%20d=%22M38.211%2016.225%2051%2021.48v-5.255z%22/%3E%3Cpath%20fill=%22%23FFF%22%20d=%22M19.12%2030.975c0-.817.662-1.475%201.483-1.475h17.794c.821%200%201.482.658%201.482%201.475v15.487c0%20.818-.661%201.475-1.482%201.475H20.603a1.476%201.476%200%200%201-1.482-1.474V30.974zm2.225%201.475h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672zm9.638-10.325h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672z%22/%3E%3C/g%3E%3C/svg%3E';
|
||||||
|
|
||||||
|
const GOOGLE_CALENDAR_ICON_SVG =
|
||||||
|
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20xmlns:xlink=%22http://www.w3.org/1999/xlink%22%20fill=%22%23fff%22%20fill-rule=%22evenodd%22%20stroke=%22%23000%22%20stroke-linecap=%22round%22%20stroke-linejoin=%22round%22%20viewBox=%220%200%2081%2082%22%3E%3Cuse%20xlink:href=%22%23a%22%20x=%22.5%22%20y=%22.5%22/%3E%3Csymbol%20id=%22a%22%20overflow=%22visible%22%3E%3Cg%20fill-rule=%22nonzero%22%20stroke=%22none%22%3E%3Cpath%20d=%22M61.052%2018.947H18.947v42.105h42.105z%22/%3E%3Cpath%20fill=%22%23ea4335%22%20d=%22M61.053%2080%2080%2061.053H61.053z%22/%3E%3Cpath%20fill=%22%23fbbc04%22%20d=%22M80%2018.947H61.053v42.105H80z%22/%3E%3Cpath%20fill=%22%2334a853%22%20d=%22M61.052%2061.053H18.947V80h42.105z%22/%3E%3Cpath%20fill=%22%23188038%22%20d=%22M0%2061.053v12.632A6.314%206.314%200%200%200%206.316%2080h12.632V61.053z%22/%3E%3Cpath%20fill=%22%231967d2%22%20d=%22M80%2018.947V6.316A6.314%206.314%200%200%200%2073.685%200H61.053v18.947z%22/%3E%3Cpath%20fill=%22%234285f4%22%20d=%22M61.053%200H6.316A6.314%206.314%200%200%200%200%206.316v54.737h18.947V18.947h42.105V0zM27.584%2051.611c-1.574-1.063-2.663-2.616-3.258-4.668l3.653-1.505q.498%201.894%201.737%202.937c1.239%201.043%201.821%201.037%202.989%201.037q1.792%200%203.079-1.089c1.287-1.089%201.29-1.653%201.29-2.774a3.44%203.44%200%200%200-1.358-2.811c-.905-.727-2.042-1.089-3.4-1.089h-2.111v-3.616H32.1q1.752%200%202.953-.947c1.201-.947%201.2-1.495%201.2-2.595q0-1.467-1.074-2.342c-1.074-.875-1.621-.879-2.721-.879q-1.61-.002-2.558.858c-.948.86-1.106%201.301-1.379%202.111l-3.616-1.505c.479-1.358%201.358-2.558%202.647-3.595s2.937-1.558%204.937-1.558q2.22-.002%203.989.858c1.769.86%202.105%201.368%202.774%202.379s1%202.153%201%203.416q0%201.932-.932%203.274c-.932%201.342-1.384%201.579-2.289%202.058v.216a6.95%206.95%200%200%201%202.937%202.289q1.146%201.538%201.147%203.684c.001%202.146-.363%202.711-1.089%203.832s-1.732%202.005-3.005%202.647c-1.279.642-2.716.968-4.311.968-1.847.005-3.553-.526-5.126-1.589zm22.437-18.126-4.01%202.9-2.005-3.042%207.195-5.189h2.758v24.479h-3.937V33.484z%22/%3E%3C/g%3E%3C/symbol%3E%3C/svg%3E';
|
||||||
|
|
||||||
|
export const processInvoicesWorkflow: PreviewWorkflow = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'gmail-trigger',
|
||||||
|
label: 'Invoice received',
|
||||||
|
icon: { type: 'file', src: GMAIL_ICON_SVG },
|
||||||
|
position: { x: 0, y: 120 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude',
|
||||||
|
label: 'Extract & cross-check',
|
||||||
|
icon: { type: 'file', src: ANTHROPIC_ICON_SVG },
|
||||||
|
position: { x: 240, y: 120 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'if-discrepancy',
|
||||||
|
label: 'Discrepancy?',
|
||||||
|
icon: { type: 'icon', name: 'node:if' },
|
||||||
|
iconColor: 'green',
|
||||||
|
position: { x: 480, y: 120 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'flag-invoice',
|
||||||
|
label: 'Flag invoice',
|
||||||
|
icon: { type: 'file', src: GOOGLE_SHEETS_ICON_SVG },
|
||||||
|
position: { x: 720, y: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'add-calendar',
|
||||||
|
label: 'Add to Calendar',
|
||||||
|
icon: { type: 'file', src: GOOGLE_CALENDAR_ICON_SVG },
|
||||||
|
position: { x: 720, y: 240 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connections: [
|
||||||
|
{ source: 'gmail-trigger', target: 'claude' },
|
||||||
|
{ source: 'claude', target: 'if-discrepancy' },
|
||||||
|
{ source: 'if-discrepancy', target: 'flag-invoice' },
|
||||||
|
{ source: 'if-discrepancy', target: 'add-calendar' },
|
||||||
|
],
|
||||||
|
inputVisualization: {
|
||||||
|
type: 'salesforce-card',
|
||||||
|
props: {
|
||||||
|
icon: GMAIL_ICON_SVG,
|
||||||
|
title: 'Invoice received',
|
||||||
|
subtitle: 'Subject: INV-2024-047',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outputVisualization: [
|
||||||
|
{
|
||||||
|
type: 'invoice-spreadsheet',
|
||||||
|
targetNodeId: 'flag-invoice',
|
||||||
|
props: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'salesforce-card',
|
||||||
|
targetNodeId: 'add-calendar',
|
||||||
|
props: {
|
||||||
|
icon: GOOGLE_CALENDAR_ICON_SVG,
|
||||||
|
title: 'Event created',
|
||||||
|
subtitle: 'Payment due · Jun 15',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,54 @@
|
||||||
|
export interface PreviewWorkflowNodeIcon {
|
||||||
|
type: 'icon' | 'file';
|
||||||
|
name?: string;
|
||||||
|
src?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewWorkflowNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: PreviewWorkflowNodeIcon;
|
||||||
|
iconColor?: string;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewWorkflowConnection {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PreviewVisualizationType =
|
||||||
|
| 'slack-message'
|
||||||
|
| 'salesforce-card'
|
||||||
|
| 'invoice-spreadsheet'
|
||||||
|
| 'whatsapp-chat';
|
||||||
|
|
||||||
|
export interface PreviewVisualization {
|
||||||
|
type: PreviewVisualizationType;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewOutputVisualization {
|
||||||
|
type: PreviewVisualizationType;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
targetNodeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmCycleVariant {
|
||||||
|
icon: PreviewWorkflowNodeIcon;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmCycleConfig {
|
||||||
|
nodeIds: string[];
|
||||||
|
variants: CrmCycleVariant[];
|
||||||
|
intervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewWorkflow {
|
||||||
|
nodes: PreviewWorkflowNode[];
|
||||||
|
connections: PreviewWorkflowConnection[];
|
||||||
|
inputVisualization?: PreviewVisualization;
|
||||||
|
outputVisualization?: PreviewVisualization | PreviewOutputVisualization[];
|
||||||
|
crmCycle?: CrmCycleConfig;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -22,6 +22,14 @@ import {
|
||||||
INSTANCE_AI_PROMPT_SUGGESTIONS_V2_VERSION,
|
INSTANCE_AI_PROMPT_SUGGESTIONS_V2_VERSION,
|
||||||
useInstanceAiPromptSuggestionsV2Experiment,
|
useInstanceAiPromptSuggestionsV2Experiment,
|
||||||
} from '@/experiments/instanceAiPromptSuggestionsV2';
|
} from '@/experiments/instanceAiPromptSuggestionsV2';
|
||||||
|
import {
|
||||||
|
WorkflowPreviewSuggestions,
|
||||||
|
WorkflowPreviewCanvas,
|
||||||
|
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS,
|
||||||
|
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION,
|
||||||
|
getPreviewWorkflow,
|
||||||
|
useInstanceAiWorkflowPreviewSuggestionsExperiment,
|
||||||
|
} from '@/experiments/instanceAiWorkflowPreviewSuggestions';
|
||||||
import InstanceAiInput from './components/InstanceAiInput.vue';
|
import InstanceAiInput from './components/InstanceAiInput.vue';
|
||||||
import InstanceAiEmptyState from './components/InstanceAiEmptyState.vue';
|
import InstanceAiEmptyState from './components/InstanceAiEmptyState.vue';
|
||||||
import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue';
|
import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue';
|
||||||
|
|
@ -33,6 +41,10 @@ const INSTANCE_AI_PROMPT_SUGGESTIONS_V2_TITLE_KEY: BaseTextKey =
|
||||||
'experiments.instanceAiPromptSuggestionsV2.emptyState.title';
|
'experiments.instanceAiPromptSuggestionsV2.emptyState.title';
|
||||||
const INSTANCE_AI_PROMPT_SUGGESTIONS_V2_PLACEHOLDER_KEY: BaseTextKey =
|
const INSTANCE_AI_PROMPT_SUGGESTIONS_V2_PLACEHOLDER_KEY: BaseTextKey =
|
||||||
'experiments.instanceAiPromptSuggestionsV2.input.placeholder';
|
'experiments.instanceAiPromptSuggestionsV2.input.placeholder';
|
||||||
|
const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_TITLE_KEY =
|
||||||
|
'experiments.instanceAiWorkflowPreviewSuggestions.emptyState.title' as BaseTextKey;
|
||||||
|
const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_PLACEHOLDER_KEY =
|
||||||
|
'experiments.instanceAiWorkflowPreviewSuggestions.input.placeholder' as BaseTextKey;
|
||||||
|
|
||||||
const store = useInstanceAiStore();
|
const store = useInstanceAiStore();
|
||||||
const { isLowCredits } = storeToRefs(store);
|
const { isLowCredits } = storeToRefs(store);
|
||||||
|
|
@ -45,7 +57,15 @@ const { isFeatureEnabled: isProactiveAgentExperimentEnabled } =
|
||||||
useInstanceAiProactiveAgentExperiment();
|
useInstanceAiProactiveAgentExperiment();
|
||||||
const { isFeatureEnabled: isPromptSuggestionsV2ExperimentEnabled } =
|
const { isFeatureEnabled: isPromptSuggestionsV2ExperimentEnabled } =
|
||||||
useInstanceAiPromptSuggestionsV2Experiment();
|
useInstanceAiPromptSuggestionsV2Experiment();
|
||||||
|
const { isFeatureEnabled: isWorkflowPreviewSuggestionsExperimentEnabled } =
|
||||||
|
useInstanceAiWorkflowPreviewSuggestionsExperiment();
|
||||||
const showProactiveStarter = computed(() => isProactiveAgentExperimentEnabled.value);
|
const showProactiveStarter = computed(() => isProactiveAgentExperimentEnabled.value);
|
||||||
|
const activeWorkflowPreviewFile = ref<string | null>(null);
|
||||||
|
const activeWorkflowPreview = computed(() => {
|
||||||
|
if (!activeWorkflowPreviewFile.value) return null;
|
||||||
|
return getPreviewWorkflow(activeWorkflowPreviewFile.value) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
// Experiment cleanup: remove with instanceAiPromptSuggestionsV2.
|
// Experiment cleanup: remove with instanceAiPromptSuggestionsV2.
|
||||||
const emptyStatePromptSuggestionProps = computed(() => {
|
const emptyStatePromptSuggestionProps = computed(() => {
|
||||||
if (showProactiveStarter.value) {
|
if (showProactiveStarter.value) {
|
||||||
|
|
@ -61,19 +81,35 @@ const emptyStatePromptSuggestionProps = computed(() => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isWorkflowPreviewSuggestionsExperimentEnabled.value) {
|
||||||
|
return {
|
||||||
|
suggestions: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS,
|
||||||
|
suggestionsComponent: WorkflowPreviewSuggestions,
|
||||||
|
suggestionCatalogVersion: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION,
|
||||||
|
placeholderKey: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_PLACEHOLDER_KEY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
suggestions: INSTANCE_AI_EMPTY_STATE_SUGGESTIONS,
|
suggestions: INSTANCE_AI_EMPTY_STATE_SUGGESTIONS,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const emptyStateTitleKey = computed<BaseTextKey>(() =>
|
const emptyStateTitleKey = computed<BaseTextKey>(() => {
|
||||||
isPromptSuggestionsV2ExperimentEnabled.value
|
if (isPromptSuggestionsV2ExperimentEnabled.value) {
|
||||||
? INSTANCE_AI_PROMPT_SUGGESTIONS_V2_TITLE_KEY
|
return INSTANCE_AI_PROMPT_SUGGESTIONS_V2_TITLE_KEY;
|
||||||
: INSTANCE_AI_DEFAULT_TITLE_KEY,
|
}
|
||||||
);
|
if (isWorkflowPreviewSuggestionsExperimentEnabled.value) {
|
||||||
|
return INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_TITLE_KEY;
|
||||||
|
}
|
||||||
|
return INSTANCE_AI_DEFAULT_TITLE_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
const chatInputRef = ref<InstanceType<typeof InstanceAiInput> | null>(null);
|
const chatInputRef = ref<InstanceType<typeof InstanceAiInput> | null>(null);
|
||||||
const isStartingThread = ref(false);
|
const isStartingThread = ref(false);
|
||||||
|
|
||||||
|
function handleWorkflowPreview(workflowFile: string | null) {
|
||||||
|
activeWorkflowPreviewFile.value = workflowFile;
|
||||||
|
}
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void nextTick(() => chatInputRef.value?.focus());
|
void nextTick(() => chatInputRef.value?.focus());
|
||||||
});
|
});
|
||||||
|
|
@ -141,8 +177,16 @@ async function handleSubmit(message: string, attachments?: InstanceAiAttachment[
|
||||||
:is-submitting="isStartingThread"
|
:is-submitting="isStartingThread"
|
||||||
v-bind="emptyStatePromptSuggestionProps"
|
v-bind="emptyStatePromptSuggestionProps"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
|
@workflow-preview="handleWorkflowPreview"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Transition name="workflow-preview-fade">
|
||||||
|
<WorkflowPreviewCanvas
|
||||||
|
v-if="isWorkflowPreviewSuggestionsExperimentEnabled && activeWorkflowPreview"
|
||||||
|
:workflow="activeWorkflowPreview"
|
||||||
|
:class="$style.workflowPreview"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -181,6 +225,11 @@ async function handleSubmit(message: string, attachments?: InstanceAiAttachment[
|
||||||
max-width: 680px;
|
max-width: 680px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflowPreview {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1600px;
|
||||||
|
}
|
||||||
|
|
||||||
.proactiveLayout {
|
.proactiveLayout {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -205,4 +254,26 @@ async function handleSubmit(message: string, attachments?: InstanceAiAttachment[
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 var(--spacing--lg) var(--spacing--sm);
|
padding: 0 var(--spacing--lg) var(--spacing--sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.workflow-preview-fade-enter-active) {
|
||||||
|
transition:
|
||||||
|
opacity 0.25s ease,
|
||||||
|
transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.workflow-preview-fade-leave-active) {
|
||||||
|
transition:
|
||||||
|
opacity 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.workflow-preview-fade-enter-from) {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.workflow-preview-fade-leave-to) {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,15 @@ const {
|
||||||
experimentMocks,
|
experimentMocks,
|
||||||
promptSuggestionsV2,
|
promptSuggestionsV2,
|
||||||
promptSuggestionsV2Component,
|
promptSuggestionsV2Component,
|
||||||
|
workflowPreviewSuggestions,
|
||||||
|
workflowPreviewSuggestionsComponent,
|
||||||
replaceMock,
|
replaceMock,
|
||||||
showErrorMock,
|
showErrorMock,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
experimentMocks: {
|
experimentMocks: {
|
||||||
proactiveAgentEnabled: { value: false },
|
proactiveAgentEnabled: { value: false },
|
||||||
promptSuggestionsV2Enabled: { value: false },
|
promptSuggestionsV2Enabled: { value: false },
|
||||||
|
workflowPreviewEnabled: { value: false },
|
||||||
},
|
},
|
||||||
promptSuggestionsV2: Array.from({ length: 12 }, (_, index) => ({
|
promptSuggestionsV2: Array.from({ length: 12 }, (_, index) => ({
|
||||||
type: 'prompt',
|
type: 'prompt',
|
||||||
|
|
@ -30,6 +33,14 @@ const {
|
||||||
promptKey: 'instanceAi.emptyState.suggestions.buildAgent.prompt',
|
promptKey: 'instanceAi.emptyState.suggestions.buildAgent.prompt',
|
||||||
})),
|
})),
|
||||||
promptSuggestionsV2Component: { name: 'InstanceAiPromptSuggestionsV2Stub' },
|
promptSuggestionsV2Component: { name: 'InstanceAiPromptSuggestionsV2Stub' },
|
||||||
|
workflowPreviewSuggestions: Array.from({ length: 4 }, (_, index) => ({
|
||||||
|
type: 'prompt',
|
||||||
|
id: `wp-suggestion-${index + 1}`,
|
||||||
|
icon: 'workflow',
|
||||||
|
labelKey: 'instanceAi.emptyState.suggestions.buildWorkflow.label',
|
||||||
|
promptKey: 'instanceAi.emptyState.suggestions.buildWorkflow.prompt',
|
||||||
|
})),
|
||||||
|
workflowPreviewSuggestionsComponent: { name: 'WorkflowPreviewSuggestionsStub' },
|
||||||
replaceMock: vi.fn(),
|
replaceMock: vi.fn(),
|
||||||
showErrorMock: vi.fn(),
|
showErrorMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
@ -53,6 +64,17 @@ vi.mock('@/experiments/instanceAiPromptSuggestionsV2', () => ({
|
||||||
InstanceAiPromptSuggestionsV2: promptSuggestionsV2Component,
|
InstanceAiPromptSuggestionsV2: promptSuggestionsV2Component,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/experiments/instanceAiWorkflowPreviewSuggestions', () => ({
|
||||||
|
useInstanceAiWorkflowPreviewSuggestionsExperiment: () => ({
|
||||||
|
isFeatureEnabled: experimentMocks.workflowPreviewEnabled,
|
||||||
|
}),
|
||||||
|
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS: workflowPreviewSuggestions,
|
||||||
|
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION: 'v3-workflow-preview',
|
||||||
|
WorkflowPreviewSuggestions: workflowPreviewSuggestionsComponent,
|
||||||
|
WorkflowPreviewCanvas: { name: 'WorkflowPreviewCanvasStub', template: '<div />' },
|
||||||
|
getPreviewWorkflow: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/app/composables/usePageRedirectionHelper', () => ({
|
vi.mock('@/app/composables/usePageRedirectionHelper', () => ({
|
||||||
usePageRedirectionHelper: () => ({ goToUpgrade: vi.fn() }),
|
usePageRedirectionHelper: () => ({ goToUpgrade: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
|
|
@ -160,6 +182,7 @@ describe('InstanceAiEmptyView', () => {
|
||||||
store.getOrCreateRuntime.mockReturnValue(thread);
|
store.getOrCreateRuntime.mockReturnValue(thread);
|
||||||
experimentMocks.proactiveAgentEnabled.value = false;
|
experimentMocks.proactiveAgentEnabled.value = false;
|
||||||
experimentMocks.promptSuggestionsV2Enabled.value = false;
|
experimentMocks.promptSuggestionsV2Enabled.value = false;
|
||||||
|
experimentMocks.workflowPreviewEnabled.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -192,6 +215,22 @@ describe('InstanceAiEmptyView', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes workflow preview suggestions, component, and catalog version when workflow preview experiment is enabled', () => {
|
||||||
|
experimentMocks.workflowPreviewEnabled.value = true;
|
||||||
|
|
||||||
|
const { getByTestId, getByText } = renderView();
|
||||||
|
|
||||||
|
expect(getByText('What do you want to automate?')).toBeVisible();
|
||||||
|
expect(getByTestId('instance-ai-input-suggestions')).toHaveTextContent('4');
|
||||||
|
expect(getByTestId('instance-ai-input-suggestions-component')).toHaveTextContent('set');
|
||||||
|
expect(getByTestId('instance-ai-input-suggestion-catalog-version')).toHaveTextContent(
|
||||||
|
'v3-workflow-preview',
|
||||||
|
);
|
||||||
|
expect(getByTestId('instance-ai-input-placeholder-key')).toHaveTextContent(
|
||||||
|
'experiments.instanceAiWorkflowPreviewSuggestions.input.placeholder',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the proactive starter and moves suggestions out of the composer when enabled', () => {
|
it('renders the proactive starter and moves suggestions out of the composer when enabled', () => {
|
||||||
experimentMocks.proactiveAgentEnabled.value = true;
|
experimentMocks.proactiveAgentEnabled.value = true;
|
||||||
experimentMocks.promptSuggestionsV2Enabled.value = true;
|
experimentMocks.promptSuggestionsV2Enabled.value = true;
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ const emit = defineEmits<{
|
||||||
submit: [message: string, attachments?: InstanceAiAttachment[]];
|
submit: [message: string, attachments?: InstanceAiAttachment[]];
|
||||||
stop: [];
|
stop: [];
|
||||||
'cancel-plan-edit': [];
|
'cancel-plan-edit': [];
|
||||||
|
'workflow-preview': [workflowFile: string | null];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
@ -135,6 +136,7 @@ watch(
|
||||||
}
|
}
|
||||||
|
|
||||||
previewPromptKey.value = null;
|
previewPromptKey.value = null;
|
||||||
|
emit('workflow-preview', null);
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
@ -372,6 +374,7 @@ const resizable = computed(() => {
|
||||||
@cycle-suggestions="handleSuggestionsCycled"
|
@cycle-suggestions="handleSuggestionsCycled"
|
||||||
@insert-suggestion="handleSuggestionInsert"
|
@insert-suggestion="handleSuggestionInsert"
|
||||||
@submit-suggestion="handleSuggestionSubmit"
|
@submit-suggestion="handleSuggestionSubmit"
|
||||||
|
@workflow-preview="emit('workflow-preview', $event)"
|
||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user