Merge branch 'master' into seatable_node_rework2

This commit is contained in:
Christoph Dyllick-Brenzinger 2024-06-27 18:29:18 +02:00 committed by GitHub
commit 9ae72e8e8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 1944 additions and 601 deletions

26
.github/workflows/notify-pr-status.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: Notify PR status changed
on:
pull_request_review:
types: [submitted, dismissed]
pull_request:
types: [closed]
jobs:
notify:
runs-on: ubuntu-latest
if: >-
(github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
(github.event_name == 'pull_request_review' && github.event.review.state == 'dismissed') ||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true) ||
(github.event_name == 'pull_request' && github.event.pull_request.merged == false && github.event.action == 'closed')
steps:
- uses: fjogeleit/http-request-action@dea46570591713c7de04a5b556bf2ff7bdf0aa9c # v1
name: Notify
env:
PR_URL: ${{ github.event.pull_request.html_url }}
with:
url: ${{ secrets.N8N_NOTIFY_PR_STATUS_CHANGED_URL }}
method: 'POST'
customHeaders: '{ "x-api-token": "${{ secrets.N8N_NOTIFY_PR_STATUS_CHANGED_TOKEN }}" }'
data: '{ "event_name": "${{ github.event_name }}", "pr_url": "${{ env.PR_URL }}", "event": ${{ toJSON(github.event) }} }'

View file

@ -1,3 +1,28 @@
# [1.48.0](https://github.com/n8n-io/n8n/compare/n8n@1.47.0...n8n@1.48.0) (2024-06-27)
### Bug Fixes
* **core:** Fix init for `AuditEventRelay` ([#9839](https://github.com/n8n-io/n8n/issues/9839)) ([16d3083](https://github.com/n8n-io/n8n/commit/16d3083af7465d0788f25d843e497b4c7d69de92))
* **core:** Fix telemetry for concurrency control ([#9845](https://github.com/n8n-io/n8n/issues/9845)) ([e25682d](https://github.com/n8n-io/n8n/commit/e25682ddad6ee961a1afe5365d7bbad871a20a4c))
* **editor:** Fix initialize authenticated features ([#9867](https://github.com/n8n-io/n8n/issues/9867)) ([4de58dc](https://github.com/n8n-io/n8n/commit/4de58dcbf5f29bf5414414aa4703356f69a29356))
* **editor:** Load credentials for workflow before determining credentials errors ([#9876](https://github.com/n8n-io/n8n/issues/9876)) ([4008c14](https://github.com/n8n-io/n8n/commit/4008c147d76daa6ff6d43f30c9a18bf1cef7e5d5))
* **editor:** Optimizing main sidebar to have more space for Projects ([#9686](https://github.com/n8n-io/n8n/issues/9686)) ([5cdcb61](https://github.com/n8n-io/n8n/commit/5cdcb61f668a6e00829bee25f40cc869376a9cd7))
* **editor:** Properly update workflow info in main header ([#9789](https://github.com/n8n-io/n8n/issues/9789)) ([1ba656e](https://github.com/n8n-io/n8n/commit/1ba656ef4aae97c78162114ad8de533b275db280))
* **editor:** Show error state correctly in options parameter with remote options ([#9836](https://github.com/n8n-io/n8n/issues/9836)) ([5bc58ef](https://github.com/n8n-io/n8n/commit/5bc58efde9c127eef8082b23cf5d8dcd91162cf4))
* **editor:** Use pinned data to resolve expressions in unexecuted nodes ([#9693](https://github.com/n8n-io/n8n/issues/9693)) ([6cb3072](https://github.com/n8n-io/n8n/commit/6cb3072a5db366404f3d16323498371d28582c06))
* Fix missing node logos ([#9844](https://github.com/n8n-io/n8n/issues/9844)) ([1eeaf32](https://github.com/n8n-io/n8n/commit/1eeaf32523c30f000a1bb8f362c478a086ca7928))
* **Zulip Node:** Fix a typo preventing some messages from updating ([#7078](https://github.com/n8n-io/n8n/issues/7078)) ([553b135](https://github.com/n8n-io/n8n/commit/553b135b0b73fa29062d2b6ef28f98c47bcd186b))
### Features
* Add RS client to hooks service ([#9834](https://github.com/n8n-io/n8n/issues/9834)) ([b807e67](https://github.com/n8n-io/n8n/commit/b807e6726f6ac86df9078c25275b6360a4fcee42))
* **Anthropic Chat Model Node:** Add support for Claude 3.5 Sonnet ([#9832](https://github.com/n8n-io/n8n/issues/9832)) ([2ce97be](https://github.com/n8n-io/n8n/commit/2ce97be33e8aa4f3023d486441ccc4860a0e07ca))
* **editor:** Show multiple nodes in input pane schema view ([#9816](https://github.com/n8n-io/n8n/issues/9816)) ([e51de9d](https://github.com/n8n-io/n8n/commit/e51de9d3916e3fcaa05e92dfb8b9b5c722bff33c))
# [1.47.0](https://github.com/n8n-io/n8n/compare/n8n@1.46.0...n8n@1.47.0) (2024-06-20)

View file

@ -42,7 +42,6 @@ n8n is split up in different modules which are all in a single mono repository.
The most important directories:
- [/docker/image](/docker/images) - Dockerfiles to create n8n containers
- [/docker/compose](/docker/compose) - Examples Docker Setups
- [/packages](/packages) - The different n8n modules
- [/packages/cli](/packages/cli) - CLI code to run front- & backend
- [/packages/core](/packages/core) - Core code which handles workflow

View file

@ -0,0 +1,135 @@
import { WorkflowPage, NDV } from '../pages';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
describe('ADO-2111 expressions should support pinned data', () => {
beforeEach(() => {
workflowPage.actions.visit();
});
it('supports pinned data in expressions unexecuted and executed parent nodes', () => {
cy.createFixtureWorkflow('Test_workflow_pinned_data_in_expressions.json', 'Expressions');
// test previous node unexecuted
workflowPage.actions.openNode('NotPinnedWithExpressions');
ndv.getters
.parameterExpressionPreview('value')
.eq(0)
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
ndv.getters
.parameterExpressionPreview('value')
.eq(1)
.should('contain.text', '0,0\nJoe\n\nJoe\n\nJoe\n\nJoe\nJoe');
// test can resolve correctly based on item
ndv.actions.switchInputMode('Table');
ndv.getters.inputTableRow(2).realHover();
cy.wait(50);
ndv.getters
.parameterExpressionPreview('value')
.eq(0)
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
ndv.getters
.parameterExpressionPreview('value')
.eq(1)
.should('contain.text', '0,1\nJoan\n\nJoan\n\nJoan\n\nJoan\nJoan');
// test previous node executed
ndv.actions.execute();
ndv.getters.inputTableRow(1).realHover();
cy.wait(50);
ndv.getters
.parameterExpressionPreview('value')
.eq(0)
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
ndv.getters
.parameterExpressionPreview('value')
.eq(1)
.should('contain.text', '0,0\nJoe\n\nJoe\n\nJoe\n\nJoe\nJoe');
ndv.getters.inputTableRow(2).realHover();
cy.wait(50);
ndv.getters
.parameterExpressionPreview('value')
.eq(0)
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
ndv.getters
.parameterExpressionPreview('value')
.eq(1)
.should('contain.text', '0,1\nJoan\n\nJoan\n\nJoan\n\nJoan\nJoan');
// check it resolved correctly on the backend
ndv.getters
.outputTbodyCell(1, 0)
.should('contain.text', 'Joe\\nJoe\\nJoan\\nJoan\\nJoe\\nJoan\\n\\nJoe\\nJoan\\n\\nJoe');
ndv.getters
.outputTbodyCell(2, 0)
.should('contain.text', 'Joe\\nJoe\\nJoan\\nJoan\\nJoe\\nJoan\\n\\nJoe\\nJoan\\n\\nJoe');
ndv.getters
.outputTbodyCell(1, 1)
.should('contain.text', '0,0\\nJoe\\n\\nJoe\\n\\nJoe\\n\\nJoe\\nJoe');
ndv.getters
.outputTbodyCell(2, 1)
.should('contain.text', '0,1\\nJoan\\n\\nJoan\\n\\nJoan\\n\\nJoan\\nJoan');
});
it('resets expressions after node is unpinned', () => {
cy.createFixtureWorkflow('Test_workflow_pinned_data_in_expressions.json', 'Expressions');
// test previous node unexecuted
workflowPage.actions.openNode('NotPinnedWithExpressions');
ndv.getters
.parameterExpressionPreview('value')
.eq(0)
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
ndv.getters
.parameterExpressionPreview('value')
.eq(1)
.should('contain.text', '0,0\nJoe\n\nJoe\n\nJoe\n\nJoe\nJoe');
ndv.actions.close();
// unpin pinned node
workflowPage.getters
.canvasNodeByName('PinnedSet')
.eq(0)
.find('.node-pin-data-icon')
.should('exist');
workflowPage.getters.canvasNodeByName('PinnedSet').eq(0).click();
workflowPage.actions.hitPinNodeShortcut();
workflowPage.getters
.canvasNodeByName('PinnedSet')
.eq(0)
.find('.node-pin-data-icon')
.should('not.exist');
workflowPage.actions.openNode('NotPinnedWithExpressions');
ndv.getters.nodeParameters().find('parameter-expression-preview-value').should('not.exist');
ndv.getters.parameterInput('value').eq(0).click();
ndv.getters
.inlineExpressionEditorOutput()
.should(
'have.text',
'[Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute previous nodes for preview][Execute previous nodes for preview][undefined]',
);
// close open expression
ndv.getters.inputLabel().eq(0).click();
ndv.getters.parameterInput('value').eq(1).click();
ndv.getters
.inlineExpressionEditorOutput()
.should(
'have.text',
'0,0[Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute previous nodes for preview][Execute previous nodes for preview][Execute previous nodes for preview]',
);
});
});

View file

@ -329,7 +329,15 @@ describe('Projects', { disableAutoLogin: true }, () => {
// Go to the first project and create a workflow
projects.getMenuItems().first().click();
workflowsPage.getters.workflowCards().should('not.have.length');
cy.intercept('GET', '/rest/credentials/for-workflow*').as('getCredentialsForWorkflow');
workflowsPage.getters.newWorkflowButtonCard().click();
cy.wait('@getCredentialsForWorkflow').then((interception) => {
expect(interception.request.query).to.have.property('projectId');
expect(interception.request.query).not.to.have.property('workflowId');
});
workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
workflowPage.getters.nodeCredentialsSelect().first().click();
@ -342,6 +350,10 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.actions.saveWorkflowOnButtonClick();
cy.reload();
cy.wait('@getCredentialsForWorkflow').then((interception) => {
expect(interception.request.query).not.to.have.property('projectId');
expect(interception.request.query).to.have.property('workflowId');
});
workflowPage.getters.canvasNodeByName(NOTION_NODE_NAME).should('be.visible').dblclick();
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()

View file

@ -0,0 +1,112 @@
{
"meta": {
"instanceId": "5bd32b91ed2a88e542012920460f736c3687a32fbb953718f6952d182231c0ff"
},
"nodes": [
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "a482f1fd-4815-4da4-a733-7beafb43c500",
"name": "static",
"value": "={{ $('PinnedSet').first().json.firstName }}\n{{ $('PinnedSet').itemMatching(0).json.firstName }}\n{{ $('PinnedSet').itemMatching(1).json.firstName }}\n{{ $('PinnedSet').last().json.firstName }}\n{{ $('PinnedSet').all()[0].json.firstName }}\n{{ $('PinnedSet').all()[1].json.firstName }}\n\n{{ $input.first().json.firstName }}\n{{ $input.last().json.firstName }}\n\n{{ $items()[0].json.firstName }}",
"type": "string"
},
{
"id": "2c973f2a-7ca0-41bc-903c-7174bee251b0",
"name": "variable",
"value": "={{ $runIndex }},{{ $itemIndex }}\n{{ $node['PinnedSet'].json.firstName }}\n\n{{ $('PinnedSet').item.json.firstName }}\n\n{{ $input.item.json.firstName }}\n\n{{ $json.firstName }}\n{{ $data.firstName }}",
"type": "string"
}
]
},
"options": {}
},
"id": "ac55ee16-4598-48bf-ace3-a48fed1d4ff3",
"name": "NotPinnedWithExpressions",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [
1600,
640
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "3058c300-b377-41b7-9c90-a01372f9b581",
"name": "firstName",
"value": "Joe",
"type": "string"
},
{
"id": "bb871662-c23c-4234-ac0c-b78c279bbf34",
"name": "lastName",
"value": "Smith",
"type": "string"
}
]
},
"options": {}
},
"id": "300a3888-cc2f-4e61-8578-b0adbcf33450",
"name": "PinnedSet",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [
1340,
640
]
},
{
"parameters": {},
"id": "426ff39a-3408-48b4-899f-60db732675f8",
"name": "Start",
"type": "n8n-nodes-base.manualTrigger",
"position": [
1100,
640
],
"typeVersion": 1
}
],
"connections": {
"PinnedSet": {
"main": [
[
{
"node": "NotPinnedWithExpressions",
"type": "main",
"index": 0
}
]
]
},
"Start": {
"main": [
[
{
"node": "PinnedSet",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"PinnedSet": [
{
"firstName": "Joe",
"lastName": "Smith"
},
{
"firstName": "Joan",
"lastName": "Summers"
}
]
}
}

View file

@ -27,6 +27,7 @@ export class NDV extends BasePage {
nodeOutputHint: () => cy.getByTestId('ndv-output-run-node-hint'),
savePinnedDataButton: () =>
this.getters.runDataPaneHeader().find('button').filter(':visible').contains('Save'),
inputLabel: () => cy.getByTestId('input-label'),
outputTableRows: () => this.getters.outputDataContainer().find('table tr'),
outputTableHeaders: () => this.getters.outputDataContainer().find('table thead th'),
outputTableHeaderByText: (text: string) => this.getters.outputTableHeaders().contains(text),

View file

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "1.47.0",
"version": "1.48.0",
"private": true,
"homepage": "https://n8n.io",
"engines": {

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/chat",
"version": "0.18.0",
"version": "0.19.0",
"scripts": {
"dev": "pnpm run storybook",
"build": "pnpm build:vite && pnpm run build:individual && npm run build:prepare",

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/client-oauth2",
"version": "0.17.0",
"version": "0.18.0",
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"

View file

@ -120,6 +120,7 @@ export class Code implements INodeType {
displayName: 'LangChain Code',
name: 'code',
icon: 'fa:code',
iconColor: 'black',
group: ['transform'],
version: 1,
description: 'LangChain Code Node',

View file

@ -0,0 +1,113 @@
import type { IExecuteFunctions, INodeType, INodeTypeDescription, SupplyData } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { VectorStoreQATool } from 'langchain/tools';
import type { VectorStore } from '@langchain/core/vectorstores';
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
import { VectorDBQAChain } from 'langchain/chains';
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
import { logWrapper } from '../../../utils/logWrapper';
export class ToolVectorStore implements INodeType {
description: INodeTypeDescription = {
displayName: 'Vector Store Tool',
name: 'toolVectorStore',
icon: 'fa:database',
group: ['transform'],
version: [1],
description: 'Retrieve context from vector store',
defaults: {
name: 'Vector Store Tool',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Tools'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolvectorstore/',
},
],
},
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [
{
displayName: 'Vector Store',
maxConnections: 1,
type: NodeConnectionType.AiVectorStore,
required: true,
},
{
displayName: 'Model',
maxConnections: 1,
type: NodeConnectionType.AiLanguageModel,
required: true,
},
],
// eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong
outputs: [NodeConnectionType.AiTool],
outputNames: ['Tool'],
properties: [
getConnectionHintNoticeField([NodeConnectionType.AiAgent]),
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'e.g. state_of_union_address',
validateType: 'string-alphanumeric',
description: 'Name of the vector store',
},
{
displayName: 'Description',
name: 'description',
type: 'string',
default: '',
placeholder: 'The most recent state of the Union address',
typeOptions: {
rows: 3,
},
},
{
displayName: 'Limit',
name: 'topK',
type: 'number',
default: 4,
description: 'The maximum number of results to return',
},
],
};
async supplyData(this: IExecuteFunctions, itemIndex: number): Promise<SupplyData> {
const name = this.getNodeParameter('name', itemIndex) as string;
const toolDescription = this.getNodeParameter('description', itemIndex) as string;
const topK = this.getNodeParameter('topK', itemIndex, 4) as number;
const vectorStore = (await this.getInputConnectionData(
NodeConnectionType.AiVectorStore,
itemIndex,
)) as VectorStore;
const llm = (await this.getInputConnectionData(
NodeConnectionType.AiLanguageModel,
0,
)) as BaseLanguageModel;
const description = VectorStoreQATool.getDescription(name, toolDescription);
const vectorStoreTool = new VectorStoreQATool(name, description, {
llm,
vectorStore,
});
vectorStoreTool.chain = VectorDBQAChain.fromLLM(llm, vectorStore, {
k: topK,
});
return {
response: logWrapper(vectorStoreTool, this),
};
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-nodes-langchain",
"version": "1.47.0",
"version": "1.48.0",
"description": "",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -100,6 +100,7 @@
"dist/nodes/tools/ToolCode/ToolCode.node.js",
"dist/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.js",
"dist/nodes/tools/ToolSerpApi/ToolSerpApi.node.js",
"dist/nodes/tools/ToolVectorStore/ToolVectorStore.node.js",
"dist/nodes/tools/ToolWikipedia/ToolWikipedia.node.js",
"dist/nodes/tools/ToolWolframAlpha/ToolWolframAlpha.node.js",
"dist/nodes/tools/ToolWorkflow/ToolWorkflow.node.js",

View file

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "1.47.0",
"version": "1.48.0",
"description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -100,7 +100,7 @@
"@n8n/permissions": "workspace:*",
"@n8n/typeorm": "0.3.20-10",
"@n8n_io/license-sdk": "2.13.0",
"@oclif/core": "3.26.6",
"@oclif/core": "4.0.7",
"@pinecone-database/pinecone": "2.1.0",
"@rudderstack/rudder-sdk-node": "2.0.7",
"@sentry/integrations": "7.87.0",

View file

@ -1,7 +1,6 @@
import 'reflect-metadata';
import { Container } from 'typedi';
import { Command } from '@oclif/core';
import { ExitError } from '@oclif/core/lib/errors';
import { Command, Errors } from '@oclif/core';
import { ApplicationError, ErrorReporterProxy as ErrorReporter, sleep } from 'n8n-workflow';
import { BinaryDataService, InstanceSettings, ObjectStoreService } from 'n8n-core';
import type { AbstractServer } from '@/AbstractServer';
@ -308,7 +307,7 @@ export abstract class BaseCommand extends Command {
await sleep(100); // give any in-flight query some time to finish
await Db.close();
}
const exitCode = error instanceof ExitError ? error.oclif.exit : error ? 1 : 0;
const exitCode = error instanceof Errors.ExitError ? error.oclif.exit : error ? 1 : 0;
this.exit(exitCode);
}

View file

@ -1,6 +1,10 @@
import { mock } from 'jest-mock-extended';
import config from '@/config';
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
import {
CLOUD_TEMP_PRODUCTION_LIMIT,
CLOUD_TEMP_REPORTABLE_THRESHOLDS,
ConcurrencyControlService,
} from '@/concurrency/concurrency-control.service';
import type { Logger } from '@/Logger';
import { InvalidConcurrencyLimitError } from '@/errors/invalid-concurrency-limit.error';
import { ConcurrencyQueue } from '../concurrency-queue';
@ -366,4 +370,91 @@ describe('ConcurrencyControlService', () => {
});
});
});
// ----------------------------------
// telemetry
// ----------------------------------
describe('telemetry', () => {
describe('on cloud', () => {
test.each(CLOUD_TEMP_REPORTABLE_THRESHOLDS)(
'for capacity %d, should report temp cloud threshold if reached',
(threshold) => {
/**
* Arrange
*/
config.set('executions.concurrency.productionLimit', CLOUD_TEMP_PRODUCTION_LIMIT);
config.set('deployment.type', 'cloud');
const service = new ConcurrencyControlService(logger, executionRepository, telemetry);
/**
* Act
*/
// @ts-expect-error Private property
service.productionQueue.emit('concurrency-check', {
capacity: CLOUD_TEMP_PRODUCTION_LIMIT - threshold,
});
/**
* Assert
*/
expect(telemetry.track).toHaveBeenCalledWith('User hit concurrency limit', { threshold });
},
);
test.each(CLOUD_TEMP_REPORTABLE_THRESHOLDS.map((t) => t - 1))(
'for capacity %d, should not report temp cloud threshold if not reached',
(threshold) => {
/**
* Arrange
*/
config.set('executions.concurrency.productionLimit', CLOUD_TEMP_PRODUCTION_LIMIT);
config.set('deployment.type', 'cloud');
const service = new ConcurrencyControlService(logger, executionRepository, telemetry);
/**
* Act
*/
// @ts-expect-error Private property
service.productionQueue.emit('concurrency-check', {
capacity: CLOUD_TEMP_PRODUCTION_LIMIT - threshold,
});
/**
* Assert
*/
expect(telemetry.track).not.toHaveBeenCalledWith('User hit concurrency limit', {
threshold,
});
},
);
test.each(CLOUD_TEMP_REPORTABLE_THRESHOLDS.map((t) => t + 1))(
'for capacity %d, should not report temp cloud threshold if exceeded',
(threshold) => {
/**
* Arrange
*/
config.set('executions.concurrency.productionLimit', CLOUD_TEMP_PRODUCTION_LIMIT);
config.set('deployment.type', 'cloud');
const service = new ConcurrencyControlService(logger, executionRepository, telemetry);
/**
* Act
*/
// @ts-expect-error Private property
service.productionQueue.emit('concurrency-check', {
capacity: CLOUD_TEMP_PRODUCTION_LIMIT - threshold,
});
/**
* Assert
*/
expect(telemetry.track).not.toHaveBeenCalledWith('User hit concurrency limit', {
threshold,
});
},
);
});
});
});

View file

@ -1,3 +1,4 @@
import { sleep } from 'n8n-workflow';
import { ConcurrencyQueue } from '../concurrency-queue';
describe('ConcurrencyQueue', () => {
@ -10,12 +11,12 @@ describe('ConcurrencyQueue', () => {
const state: Record<string, 'started' | 'finished'> = {};
// eslint-disable-next-line @typescript-eslint/promise-function-async
const sleep = jest.fn(() => new Promise((resolve) => setTimeout(resolve, 500)));
const sleepSpy = jest.fn(() => sleep(500));
const testFn = async (item: { executionId: string }) => {
await queue.enqueue(item.executionId);
state[item.executionId] = 'started';
await sleep();
await sleepSpy();
queue.dequeue();
state[item.executionId] = 'finished';
};
@ -29,33 +30,46 @@ describe('ConcurrencyQueue', () => {
]);
// At T+0 seconds this method hasn't yielded to the event-loop, so no `testFn` calls are made
expect(sleep).toHaveBeenCalledTimes(0);
expect(sleepSpy).toHaveBeenCalledTimes(0);
expect(state).toEqual({});
// At T+0.4 seconds the first `testFn` has been called, but hasn't resolved
await jest.advanceTimersByTimeAsync(400);
expect(sleep).toHaveBeenCalledTimes(1);
expect(sleepSpy).toHaveBeenCalledTimes(1);
expect(state).toEqual({ 1: 'started' });
// At T+0.5 seconds the first promise has resolved, and the second one has stared
await jest.advanceTimersByTimeAsync(100);
expect(sleep).toHaveBeenCalledTimes(2);
expect(sleepSpy).toHaveBeenCalledTimes(2);
expect(state).toEqual({ 1: 'finished', 2: 'started' });
// At T+1 seconds the first two promises have resolved, and the third one has stared
await jest.advanceTimersByTimeAsync(500);
expect(sleep).toHaveBeenCalledTimes(3);
expect(sleepSpy).toHaveBeenCalledTimes(3);
expect(state).toEqual({ 1: 'finished', 2: 'finished', 3: 'started' });
// If the fourth promise is removed, the fifth one is started in the next tick
queue.remove('4');
await jest.advanceTimersByTimeAsync(1);
expect(sleep).toHaveBeenCalledTimes(4);
expect(sleepSpy).toHaveBeenCalledTimes(4);
expect(state).toEqual({ 1: 'finished', 2: 'finished', 3: 'started', 5: 'started' });
// at T+5 seconds, all but the fourth promise should be resolved
await jest.advanceTimersByTimeAsync(4000);
expect(sleep).toHaveBeenCalledTimes(4);
expect(sleepSpy).toHaveBeenCalledTimes(4);
expect(state).toEqual({ 1: 'finished', 2: 'finished', 3: 'finished', 5: 'finished' });
});
it('should debounce emitting of the `concurrency-check` event', async () => {
const queue = new ConcurrencyQueue(10);
const emitSpy = jest.fn();
queue.on('concurrency-check', emitSpy);
// eslint-disable-next-line @typescript-eslint/promise-function-async
Array.from({ length: 10 }, (_, i) => i).forEach(() => queue.enqueue('1'));
expect(queue.currentCapacity).toBe(0);
await jest.advanceTimersByTimeAsync(1000);
expect(emitSpy).toHaveBeenCalledTimes(1);
});
});

View file

@ -9,6 +9,9 @@ import type { WorkflowExecuteMode as ExecutionMode } from 'n8n-workflow';
import type { IExecutingWorkflowData } from '@/Interfaces';
import { Telemetry } from '@/telemetry';
export const CLOUD_TEMP_PRODUCTION_LIMIT = 999;
export const CLOUD_TEMP_REPORTABLE_THRESHOLDS = [5, 10, 20, 50, 100, 200];
@Service()
export class ConcurrencyControlService {
private readonly isEnabled: boolean;
@ -17,7 +20,9 @@ export class ConcurrencyControlService {
private readonly productionQueue: ConcurrencyQueue;
private readonly limitsToReport = [5, 10, 20, 50, 100, 200];
private readonly limitsToReport = CLOUD_TEMP_REPORTABLE_THRESHOLDS.map(
(t) => CLOUD_TEMP_PRODUCTION_LIMIT - t,
);
constructor(
private readonly logger: Logger,
@ -46,19 +51,17 @@ export class ConcurrencyControlService {
this.isEnabled = true;
this.productionQueue.on(
'execution-throttled',
async ({ executionId, capacity }: { executionId: string; capacity: number }) => {
this.log('Execution throttled', { executionId });
/**
* Temporary until base data for cloud plans is collected.
*/
this.productionQueue.on('concurrency-check', ({ capacity }: { capacity: number }) => {
if (this.shouldReport(capacity)) {
await this.telemetry.track('User hit concurrency limit', { threshold: capacity });
void this.telemetry.track('User hit concurrency limit', {
threshold: CLOUD_TEMP_PRODUCTION_LIMIT - capacity,
});
}
},
);
});
this.productionQueue.on('execution-throttled', ({ executionId }: { executionId: string }) => {
this.log('Execution throttled', { executionId });
});
this.productionQueue.on('execution-released', async (executionId: string) => {
this.log('Execution released', { executionId });

View file

@ -1,5 +1,6 @@
import { Service } from 'typedi';
import { EventEmitter } from 'node:events';
import debounce from 'lodash/debounce';
@Service()
export class ConcurrencyQueue extends EventEmitter {
@ -15,14 +16,20 @@ export class ConcurrencyQueue extends EventEmitter {
async enqueue(executionId: string) {
this.capacity--;
this.debouncedEmit('concurrency-check', { capacity: this.capacity });
if (this.capacity < 0) {
this.emit('execution-throttled', { executionId, capacity: this.capacity });
this.emit('execution-throttled', { executionId });
// eslint-disable-next-line @typescript-eslint/return-await
return new Promise<void>((resolve) => this.queue.push({ executionId, resolve }));
}
}
get currentCapacity() {
return this.capacity;
}
dequeue() {
this.capacity++;
@ -56,4 +63,9 @@ export class ConcurrencyQueue extends EventEmitter {
resolve();
}
private debouncedEmit = debounce(
(event: string, payload: object) => this.emit(event, payload),
300,
);
}

View file

@ -1,6 +1,6 @@
{
"name": "n8n-core",
"version": "1.47.0",
"version": "1.48.0",
"description": "Core functionality of n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",

View file

@ -1,6 +1,6 @@
{
"name": "n8n-design-system",
"version": "1.37.0",
"version": "1.38.0",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
"author": {

View file

@ -79,16 +79,19 @@
--color-sticky-border-7: var(--prim-gray-670);
// NodeIcon
--color-node-icon-gray: var(--prim-gray-200);
--color-node-icon-black: var(--prim-gray-70);
--color-node-icon-blue: #766dfb;
--color-node-icon-dark-blue: #6275ad;
--color-node-icon-gray: var(--prim-gray-120);
--color-node-icon-black: var(--prim-gray-10);
--color-node-icon-blue: #898fff;
--color-node-icon-light-blue: #58abff;
--color-node-icon-dark-blue: #7ba7ff;
--color-node-icon-orange-red: var(--prim-color-primary);
--color-node-icon-pink-red: #f85d82;
--color-node-icon-red: var(--prim-color-alt-k);
--color-node-icon-light-green: #20b69e;
--color-node-icon-green: #38cb7a;
--color-node-icon-dark-green: #86decc;
--color-node-icon-purple: #9b6dd5;
--color-node-icon-crimson: #d05876;
--color-node-icon-crimson: #f188a2;
// Expressions, autocomplete, infobox
--color-valid-resolvable-foreground: var(--prim-color-alt-a-tint-300);

View file

@ -1,6 +1,6 @@
{
"name": "n8n-editor-ui",
"version": "1.47.0",
"version": "1.48.0",
"description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",

View file

@ -6,16 +6,20 @@ export function createCanvasNodeData({
id = 'node',
type = 'test',
typeVersion = 1,
disabled = false,
inputs = [],
outputs = [],
connections = { input: {}, output: {} },
renderType = 'default',
}: Partial<CanvasElementData> = {}): CanvasElementData {
return {
id,
type,
typeVersion,
disabled,
inputs,
outputs,
connections,
renderType,
};
}

View file

@ -29,12 +29,14 @@ export const mockNode = ({
name,
type,
position = [0, 0],
disabled = false,
}: {
id?: INode['id'];
name: INode['name'];
type: INode['type'];
position?: INode['position'];
}) => mock<INode>({ id, name, type, position });
disabled?: INode['disabled'];
}) => mock<INode>({ id, name, type, position, disabled });
export const mockNodeTypeDescription = ({
name,

View file

@ -53,7 +53,7 @@ export const codeNodeEditorTheme = ({
},
'.cm-content': {
fontFamily: BASE_STYLING.fontFamily,
caretColor: 'var(--color-code-caret)',
caretColor: isReadOnly ? 'transparent' : 'var(--color-code-caret)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--color-code-caret)',

View file

@ -613,6 +613,7 @@ function copySuccess() {
}
&__header-description {
overflow: hidden;
padding: 0 var(--spacing-s) var(--spacing-3xs) var(--spacing-s);
font-size: var(--font-size-xs);

View file

@ -97,7 +97,6 @@ onMounted(() => {
extensions: [
EditorState.readOnly.of(true),
EditorView.lineWrapping,
EditorView.editable.of(false),
EditorView.domEventHandlers({ scroll: forceParse }),
...props.extensions,
],

View file

@ -53,7 +53,7 @@ const extensions = computed(() => [
),
n8nLang(),
n8nAutocompletion(),
inputTheme({ rows: props.rows }),
inputTheme({ isReadOnly: props.isReadOnly, rows: props.rows }),
history(),
expressionInputHandler(),
EditorView.lineWrapping,

View file

@ -1,24 +1,24 @@
import { EditorView } from '@codemirror/view';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
const commonThemeProps = {
const commonThemeProps = (isReadOnly = false) => ({
'&.cm-focused': {
outline: '0 !important',
},
'.cm-content': {
fontFamily: 'var(--font-family-monospace)',
color: 'var(--input-font-color, var(--color-text-dark))',
caretColor: 'var(--color-code-caret)',
caretColor: isReadOnly ? 'transparent' : 'var(--color-code-caret)',
},
'.cm-line': {
padding: '0',
},
};
});
export const inputTheme = ({ rows } = { rows: 5 }) => {
export const inputTheme = ({ rows, isReadOnly } = { rows: 5, isReadOnly: false }) => {
const maxHeight = Math.max(rows * 22 + 8);
const theme = EditorView.theme({
...commonThemeProps,
...commonThemeProps(isReadOnly),
'&': {
maxHeight: `${maxHeight}px`,
minHeight: '30px',
@ -54,7 +54,7 @@ export const inputTheme = ({ rows } = { rows: 5 }) => {
export const outputTheme = () => {
const theme = EditorView.theme({
...commonThemeProps,
...commonThemeProps(true),
'&': {
maxHeight: '95px',
width: '100%',

View file

@ -61,7 +61,6 @@ const extensions = computed(() => {
lineNumbers(),
EditorView.lineWrapping,
EditorState.readOnly.of(props.isReadOnly),
EditorView.editable.of(!props.isReadOnly),
codeNodeEditorTheme({
isReadOnly: props.isReadOnly,
maxHeight: props.fillParent ? '100%' : '40vh',

View file

@ -54,7 +54,6 @@ const extensions = computed(() => {
lineNumbers(),
EditorView.lineWrapping,
EditorState.readOnly.of(props.isReadOnly),
EditorView.editable.of(!props.isReadOnly),
codeNodeEditorTheme({
isReadOnly: props.isReadOnly,
maxHeight: props.fillParent ? '100%' : '40vh',

View file

@ -61,7 +61,6 @@ describe('ExpressionParameterInput', () => {
await waitFor(() => {
const editor = container.querySelector('.cm-content') as HTMLDivElement;
expect(editor).toBeInTheDocument();
expect(editor.getAttribute('contenteditable')).toEqual('false');
expect(editor.getAttribute('aria-readonly')).toEqual('true');
});
});

View file

@ -15,6 +15,7 @@ const emit = defineEmits<{
'update:modelValue': [elements: CanvasElement[]];
'update:node:position': [id: string, position: XYPosition];
'update:node:active': [id: string];
'update:node:enabled': [id: string];
'update:node:selected': [id?: string];
'delete:node': [id: string];
'delete:connection': [connection: Connection];
@ -66,6 +67,10 @@ function onSelectNode() {
emit('update:node:selected', selectedNodeId);
}
function onToggleNodeEnabled(id: string) {
emit('update:node:enabled', id);
}
function onDeleteNode(id: string) {
emit('delete:node', id);
}
@ -126,6 +131,7 @@ function onClickPane(event: MouseEvent) {
v-bind="canvasNodeProps"
@delete="onDeleteNode"
@select="onSelectNode"
@toggle="onToggleNodeEnabled"
@activate="onSetNodeActive"
/>
</template>

View file

@ -18,21 +18,24 @@ import type { NodeProps } from '@vue-flow/core';
const emit = defineEmits<{
delete: [id: string];
select: [id: string, selected: boolean];
toggle: [id: string];
activate: [id: string];
}>();
const props = defineProps<NodeProps<CanvasElementData>>();
const inputs = computed(() => props.data.inputs);
const outputs = computed(() => props.data.outputs);
const nodeTypesStore = useNodeTypesStore();
const inputs = computed(() => props.data.inputs);
const outputs = computed(() => props.data.outputs);
const connections = computed(() => props.data.connections);
const { mainInputs, nonMainInputs, mainOutputs, nonMainOutputs } = useNodeConnections({
inputs,
outputs,
connections,
});
const isDisabled = computed(() => props.data.disabled);
const nodeType = computed(() => {
return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion);
});
@ -107,6 +110,10 @@ function onDelete() {
emit('delete', props.id);
}
function onDisabledToggle() {
emit('toggle', props.id);
}
function onActivate() {
emit('activate', props.id);
}
@ -143,12 +150,12 @@ function onActivate() {
data-test-id="canvas-node-toolbar"
:class="$style.canvasNodeToolbar"
@delete="onDelete"
@toggle="onDisabledToggle"
/>
<CanvasNodeRenderer v-if="nodeType" @dblclick="onActivate">
<NodeIcon :node-type="nodeType" :size="40" :shrink="false" />
<NodeIcon :node-type="nodeType" :size="40" :shrink="false" :disabled="isDisabled" />
<!-- :color-default="iconColorDefault"-->
<!-- :disabled="data.disabled"-->
</CanvasNodeRenderer>
</div>
</template>

View file

@ -53,39 +53,39 @@ describe('CanvasNodeToolbar', () => {
});
it('should call toggleDisableNode function when disable node button is clicked', async () => {
const toggleDisableNode = vi.fn();
const onToggleNode = vi.fn();
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
mocks: {
toggleDisableNode,
onToggleNode,
},
},
});
await fireEvent.click(getByTestId('disable-node-button'));
expect(toggleDisableNode).toHaveBeenCalled();
expect(onToggleNode).toHaveBeenCalled();
});
it('should call deleteNode function when delete node button is clicked', async () => {
const deleteNode = vi.fn();
const onDeleteNode = vi.fn();
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
mocks: {
deleteNode,
onDeleteNode,
},
},
});
await fireEvent.click(getByTestId('delete-node-button'));
expect(deleteNode).toHaveBeenCalled();
expect(onDeleteNode).toHaveBeenCalled();
});
it('should call openContextMenu function when overflow node button is clicked', async () => {

View file

@ -3,12 +3,14 @@ import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey } from '@/constants';
import { useI18n } from '@/composables/useI18n';
const emit = defineEmits(['delete']);
const emit = defineEmits<{
delete: [];
toggle: [];
}>();
const $style = useCssModule();
const node = inject(CanvasNodeKey);
const i18n = useI18n();
const node = inject(CanvasNodeKey);
const data = computed(() => node?.data.value);
@ -21,10 +23,11 @@ const nodeDisabledTitle = 'Test';
// @TODO
function executeNode() {}
// @TODO
function toggleDisableNode() {}
function onToggleNode() {
emit('toggle');
}
function deleteNode() {
function onDeleteNode() {
emit('delete');
}
@ -53,7 +56,7 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
size="small"
icon="power-off"
:title="nodeDisabledTitle"
@click="toggleDisableNode"
@click="onToggleNode"
/>
<N8nIconButton
data-test-id="delete-node-button"
@ -62,7 +65,7 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
text
icon="trash"
:title="i18n.baseText('node.delete')"
@click="deleteNode"
@click="onDeleteNode"
/>
<N8nIconButton
data-test-id="overflow-node-button"

View file

@ -44,6 +44,36 @@ describe('CanvasNodeConfigurable', () => {
});
});
describe('disabled', () => {
it('should apply disabled class when node is disabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
disabled: true,
},
}),
},
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('disabled');
expect(getByText('(Deactivated)')).toBeVisible();
});
it('should not apply disabled class when node is enabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node').closest('.node')).not.toHaveClass('disabled');
});
});
describe('inputs', () => {
it('should adjust width css variable based on the number of non-main inputs', () => {
const { getByText } = renderComponent({

View file

@ -2,24 +2,32 @@
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey, NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { useI18n } from '@/composables/useI18n';
import CanvasNodeDisabledStrikeThrough from './parts/CanvasNodeDisabledStrikeThrough.vue';
const node = inject(CanvasNodeKey);
const $style = useCssModule();
const i18n = useI18n();
const label = computed(() => node?.label.value ?? '');
const inputs = computed(() => node?.data.value.inputs ?? []);
const outputs = computed(() => node?.data.value.outputs ?? []);
const connections = computed(() => node?.data.value.connections ?? { input: {}, output: {} });
const { nonMainInputs, requiredNonMainInputs } = useNodeConnections({
inputs,
outputs,
connections,
});
const isDisabled = computed(() => node?.data.value.disabled ?? false);
const classes = computed(() => {
return {
[$style.node]: true,
[$style.selected]: node?.selected.value,
[$style.disabled]: isDisabled.value,
};
});
@ -46,7 +54,11 @@ const styles = computed(() => {
<template>
<div :class="classes" :style="styles" data-test-id="canvas-node-configurable">
<slot />
<div :class="$style.label">{{ label }}</div>
<CanvasNodeDisabledStrikeThrough v-if="isDisabled" />
<div :class="$style.label">
{{ label }}
<div v-if="isDisabled">({{ i18n.baseText('node.disabled') }})</div>
</div>
</div>
</template>
@ -54,12 +66,14 @@ const styles = computed(() => {
.node {
--configurable-node-min-input-count: 4;
--configurable-node-input-width: 65px;
width: calc(
--canvas-node--height: 100px;
--canvas-node--width: calc(
max(var(--configurable-node-input-count, 5), var(--configurable-node-min-input-count)) *
var(--configurable-node-input-width)
);
height: 100px;
width: var(--canvas-node--width);
height: var(--canvas-node--height);
display: flex;
align-items: center;
justify-content: center;
@ -82,4 +96,8 @@ const styles = computed(() => {
.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
.disabled {
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
}
</style>

View file

@ -40,4 +40,34 @@ describe('CanvasNodeConfiguration', () => {
expect(getByText('Test Node').closest('.node')).not.toHaveClass('selected');
});
});
describe('disabled', () => {
it('should apply disabled class when node is disabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
disabled: true,
},
}),
},
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('disabled');
expect(getByText('(Deactivated)')).toBeVisible();
});
it('should not apply disabled class when node is enabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node').closest('.node')).not.toHaveClass('disabled');
});
});
});

View file

@ -1,17 +1,22 @@
<script lang="ts" setup>
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey } from '@/constants';
import { useI18n } from '@/composables/useI18n';
const node = inject(CanvasNodeKey);
const $style = useCssModule();
const i18n = useI18n();
const label = computed(() => node?.label.value ?? '');
const isDisabled = computed(() => node?.data.value.disabled ?? false);
const classes = computed(() => {
return {
[$style.node]: true,
[$style.selected]: node?.selected.value,
[$style.disabled]: isDisabled.value,
};
});
</script>
@ -19,14 +24,20 @@ const classes = computed(() => {
<template>
<div :class="classes" data-test-id="canvas-node-configuration">
<slot />
<div v-if="label" :class="$style.label">{{ label }}</div>
<div v-if="label" :class="$style.label">
{{ label }}
<div v-if="isDisabled">({{ i18n.baseText('node.disabled') }})</div>
</div>
</div>
</template>
<style lang="scss" module>
.node {
width: 75px;
height: 75px;
--canvas-node--width: 75px;
--canvas-node--height: 75px;
width: var(--canvas-node--width);
height: var(--canvas-node--height);
display: flex;
align-items: center;
justify-content: center;
@ -35,10 +46,6 @@ const classes = computed(() => {
border-radius: 50%;
}
.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
.label {
top: 100%;
position: absolute;
@ -48,4 +55,12 @@ const classes = computed(() => {
min-width: 200px;
margin-top: var(--spacing-2xs);
}
.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
.disabled {
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
}
</style>

View file

@ -81,4 +81,34 @@ describe('CanvasNodeDefault', () => {
expect(getByText('Test Node').closest('.node')).not.toHaveClass('selected');
});
});
describe('disabled', () => {
it('should apply disabled class when node is disabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
disabled: true,
},
}),
},
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('disabled');
expect(getByText('(Deactivated)')).toBeVisible();
});
it('should not apply disabled class when node is enabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node').closest('.node')).not.toHaveClass('disabled');
});
});
});

View file

@ -2,24 +2,32 @@
import { computed, inject, useCssModule } from 'vue';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { CanvasNodeKey } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import CanvasNodeDisabledStrikeThrough from './parts/CanvasNodeDisabledStrikeThrough.vue';
const node = inject(CanvasNodeKey);
const $style = useCssModule();
const i18n = useI18n();
const label = computed(() => node?.label.value ?? '');
const inputs = computed(() => node?.data.value.inputs ?? []);
const outputs = computed(() => node?.data.value.outputs ?? []);
const connections = computed(() => node?.data.value.connections ?? { input: {}, output: {} });
const { mainOutputs } = useNodeConnections({
inputs,
outputs,
connections,
});
const isDisabled = computed(() => node?.data.value.disabled ?? false);
const classes = computed(() => {
return {
[$style.node]: true,
[$style.selected]: node?.selected.value,
[$style.disabled]: isDisabled.value,
};
});
@ -33,14 +41,21 @@ const styles = computed(() => {
<template>
<div v-if="node" :class="classes" :style="styles" data-test-id="canvas-node-default">
<slot />
<div v-if="label" :class="$style.label">{{ label }}</div>
<CanvasNodeDisabledStrikeThrough v-if="isDisabled" />
<div v-if="label" :class="$style.label">
{{ label }}
<div v-if="isDisabled">({{ i18n.baseText('node.disabled') }})</div>
</div>
</div>
</template>
<style lang="scss" module>
.node {
height: calc(100px + max(0, var(--node-main-output-count, 1) - 4) * 50px);
width: 100px;
--canvas-node--height: calc(100px + max(0, var(--node-main-output-count, 1) - 4) * 50px);
--canvas-node--width: 100px;
height: var(--canvas-node--height);
width: var(--canvas-node--width);
display: flex;
align-items: center;
justify-content: center;
@ -62,4 +77,8 @@ const styles = computed(() => {
.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
.disabled {
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
}
</style>

View file

@ -0,0 +1,35 @@
import CanvasNodeDisabledStrikeThrough from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeDisabledStrikeThrough.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { NodeConnectionType } from 'n8n-workflow';
import { createCanvasNodeProvide } from '@/__tests__/data';
const renderComponent = createComponentRenderer(CanvasNodeDisabledStrikeThrough);
describe('CanvasNodeDisabledStrikeThrough', () => {
it('should render node correctly', () => {
const { container } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
connections: {
input: {
[NodeConnectionType.Main]: [
[{ node: 'node', type: NodeConnectionType.Main, index: 0 }],
],
},
output: {
[NodeConnectionType.Main]: [
[{ node: 'node', type: NodeConnectionType.Main, index: 0 }],
],
},
},
},
}),
},
},
});
expect(container.firstChild).toBeVisible();
});
});

View file

@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey } from '@/constants';
import { useNodeConnections } from '@/composables/useNodeConnections';
const $style = useCssModule();
const node = inject(CanvasNodeKey);
const inputs = computed(() => node?.data.value.inputs ?? []);
const outputs = computed(() => node?.data.value.outputs ?? []);
const connections = computed(() => node?.data.value.connections ?? { input: {}, output: {} });
const { mainInputConnections, mainOutputConnections } = useNodeConnections({
inputs,
outputs,
connections,
});
const isVisible = computed(
() => mainInputConnections.value.length === 1 && mainOutputConnections.value.length === 1,
);
const isSuccessStatus = computed(
() => false,
// @TODO Implement this
// () => !['unknown'].includes(node.status) && workflowDataItems > 0,
);
const classes = computed(() => {
return {
[$style.disabledStrikeThrough]: true,
[$style.success]: isSuccessStatus.value,
};
});
</script>
<template>
<div v-if="isVisible" :class="classes"></div>
</template>
<style lang="scss" module>
.disabledStrikeThrough {
border: 1px solid var(--color-foreground-dark);
position: absolute;
top: calc(var(--canvas-node--height) / 2 - 1px);
left: -4px;
width: calc(100% + 12px);
pointer-events: none;
}
.success {
border-color: var(--color-success-light);
}
</style>

View file

@ -7,7 +7,8 @@ import { mock } from 'vitest-mock-extended';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
import type { IWorkflowDb } from '@/Interface';
import { createTestWorkflowObject, mockNodes } from '@/__tests__/mocks';
import { createTestWorkflowObject, mockNode, mockNodes } from '@/__tests__/mocks';
import { MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
vi.mock('@/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({
@ -48,7 +49,11 @@ describe('useCanvasMapping', () => {
describe('elements', () => {
it('should map nodes to canvas elements', () => {
const manualTriggerNode = mockNodes[0];
const manualTriggerNode = mockNode({
name: 'Manual Trigger',
type: MANUAL_TRIGGER_NODE_TYPE,
disabled: false,
});
const workflow = mock<IWorkflowDb>({
nodes: [manualTriggerNode],
});
@ -69,13 +74,75 @@ describe('useCanvasMapping', () => {
id: manualTriggerNode.id,
type: manualTriggerNode.type,
typeVersion: expect.anything(),
disabled: false,
inputs: [],
outputs: [],
connections: {
input: {},
output: {},
},
renderType: 'default',
},
},
]);
});
it('should handle node disabled state', () => {
const manualTriggerNode = mockNode({
name: 'Manual Trigger',
type: MANUAL_TRIGGER_NODE_TYPE,
disabled: true,
});
const workflow = mock<IWorkflowDb>({
nodes: [manualTriggerNode],
});
const workflowObject = createTestWorkflowObject(workflow);
const { elements } = useCanvasMapping({
workflow: ref(workflow),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(elements.value[0]?.data?.disabled).toEqual(true);
});
it('should handle input and output connections', () => {
const [manualTriggerNode, setNode] = mockNodes.slice(0, 2);
const workflow = mock<IWorkflowDb>({
nodes: [manualTriggerNode, setNode],
connections: {
[manualTriggerNode.name]: {
[NodeConnectionType.Main]: [
[{ node: setNode.name, type: NodeConnectionType.Main, index: 0 }],
],
},
},
});
const workflowObject = createTestWorkflowObject(workflow);
const { elements } = useCanvasMapping({
workflow: ref(workflow),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(elements.value[0]?.data?.connections.output).toHaveProperty(NodeConnectionType.Main);
expect(elements.value[0]?.data?.connections.output[NodeConnectionType.Main][0][0]).toEqual(
expect.objectContaining({
node: setNode.name,
type: NodeConnectionType.Main,
index: 0,
}),
);
expect(elements.value[1]?.data?.connections.input).toHaveProperty(NodeConnectionType.Main);
expect(elements.value[1]?.data?.connections.input[NodeConnectionType.Main][0][0]).toEqual(
expect.objectContaining({
node: manualTriggerNode.name,
type: NodeConnectionType.Main,
index: 0,
}),
);
});
});
describe('connections', () => {

View file

@ -4,6 +4,7 @@ import { useNodeConnections } from '@/composables/useNodeConnections';
import type { CanvasElementData } from '@/types';
describe('useNodeConnections', () => {
const defaultConnections = { input: {}, output: {} };
describe('mainInputs', () => {
it('should return main inputs when provided with main inputs', () => {
const inputs = ref<CanvasElementData['inputs']>([
@ -14,7 +15,11 @@ describe('useNodeConnections', () => {
]);
const outputs = ref<CanvasElementData['outputs']>([]);
const { mainInputs } = useNodeConnections({ inputs, outputs });
const { mainInputs } = useNodeConnections({
inputs,
outputs,
connections: defaultConnections,
});
expect(mainInputs.value.length).toBe(3);
expect(mainInputs.value).toEqual(inputs.value.slice(0, 3));
@ -30,7 +35,11 @@ describe('useNodeConnections', () => {
]);
const outputs = ref<CanvasElementData['outputs']>([]);
const { nonMainInputs } = useNodeConnections({ inputs, outputs });
const { nonMainInputs } = useNodeConnections({
inputs,
outputs,
connections: defaultConnections,
});
expect(nonMainInputs.value.length).toBe(2);
expect(nonMainInputs.value).toEqual(inputs.value.slice(1));
@ -46,13 +55,42 @@ describe('useNodeConnections', () => {
]);
const outputs = ref<CanvasElementData['outputs']>([]);
const { requiredNonMainInputs } = useNodeConnections({ inputs, outputs });
const { requiredNonMainInputs } = useNodeConnections({
inputs,
outputs,
connections: defaultConnections,
});
expect(requiredNonMainInputs.value.length).toBe(1);
expect(requiredNonMainInputs.value).toEqual([inputs.value[1]]);
});
});
describe('mainInputConnections', () => {
it('should return main input connections when provided with main input connections', () => {
const inputs = ref<CanvasElementData['inputs']>([]);
const outputs = ref<CanvasElementData['outputs']>([]);
const connections = ref<CanvasElementData['connections']>({
input: {
[NodeConnectionType.Main]: [
[{ node: 'node1', type: NodeConnectionType.Main, index: 0 }],
[{ node: 'node2', type: NodeConnectionType.Main, index: 0 }],
],
},
output: {},
});
const { mainInputConnections } = useNodeConnections({
inputs,
outputs,
connections,
});
expect(mainInputConnections.value.length).toBe(2);
expect(mainInputConnections.value).toEqual(connections.value.input[NodeConnectionType.Main]);
});
});
describe('mainOutputs', () => {
it('should return main outputs when provided with main outputs', () => {
const inputs = ref<CanvasElementData['inputs']>([]);
@ -63,7 +101,11 @@ describe('useNodeConnections', () => {
{ type: NodeConnectionType.AiAgent, index: 0 },
]);
const { mainOutputs } = useNodeConnections({ inputs, outputs });
const { mainOutputs } = useNodeConnections({
inputs,
outputs,
connections: defaultConnections,
});
expect(mainOutputs.value.length).toBe(3);
expect(mainOutputs.value).toEqual(outputs.value.slice(0, 3));
@ -79,10 +121,41 @@ describe('useNodeConnections', () => {
{ type: NodeConnectionType.AiAgent, index: 1 },
]);
const { nonMainOutputs } = useNodeConnections({ inputs, outputs });
const { nonMainOutputs } = useNodeConnections({
inputs,
outputs,
connections: defaultConnections,
});
expect(nonMainOutputs.value.length).toBe(2);
expect(nonMainOutputs.value).toEqual(outputs.value.slice(1));
});
});
describe('mainOutputConnections', () => {
it('should return main output connections when provided with main output connections', () => {
const inputs = ref<CanvasElementData['inputs']>([]);
const outputs = ref<CanvasElementData['outputs']>([]);
const connections = ref<CanvasElementData['connections']>({
input: {},
output: {
[NodeConnectionType.Main]: [
[{ node: 'node1', type: NodeConnectionType.Main, index: 0 }],
[{ node: 'node2', type: NodeConnectionType.Main, index: 0 }],
],
},
});
const { mainOutputConnections } = useNodeConnections({
inputs,
outputs,
connections,
});
expect(mainOutputConnections.value.length).toBe(2);
expect(mainOutputConnections.value).toEqual(
connections.value.output[NodeConnectionType.Main],
);
});
});
});

View file

@ -89,12 +89,20 @@ export function useCanvasMapping({
const elements = computed<CanvasElement[]>(() => [
...workflow.value.nodes.map<CanvasElement>((node) => {
const inputConnections = workflowObject.value.connectionsByDestinationNode[node.name] ?? {};
const outputConnections = workflowObject.value.connectionsBySourceNode[node.name] ?? {};
const data: CanvasElementData = {
id: node.id,
type: node.type,
typeVersion: node.typeVersion,
disabled: !!node.disabled,
inputs: nodeInputsById.value[node.id] ?? [],
outputs: nodeOutputsById.value[node.id] ?? [],
connections: {
input: inputConnections,
output: outputConnections,
},
renderType: renderTypeByNodeType.value[node.type] ?? 'default',
};

View file

@ -46,6 +46,7 @@ import { useCredentialsStore } from '@/stores/credentials.store';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import type { useRouter } from 'vue-router';
import { useCanvasStore } from '@/stores/canvas.store';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
type AddNodeData = {
name?: string;
@ -78,6 +79,7 @@ export function useCanvasOperations({
const i18n = useI18n();
const toast = useToast();
const workflowHelpers = useWorkflowHelpers({ router });
const nodeHelpers = useNodeHelpers();
const telemetry = useTelemetry();
const externalHooks = useExternalHooks();
@ -235,6 +237,18 @@ export function useCanvasOperations({
uiStore.lastSelectedNode = node.name;
}
function toggleNodeDisabled(
id: string,
{ trackHistory = true }: { trackHistory?: boolean } = {},
) {
const node = workflowsStore.getNodeById(id);
if (!node) {
return;
}
nodeHelpers.disableNodes([node], trackHistory);
}
async function addNodes(
nodes: AddedNodesAndConnections['nodes'],
{
@ -886,6 +900,7 @@ export function useCanvasOperations({
setNodeActive,
setNodeActiveByName,
setNodeSelected,
toggleNodeDisabled,
renameNode,
revertRenameNode,
deleteNode,

View file

@ -185,10 +185,7 @@ export const useExpressionEditor = ({
doc: toValue(editorValue),
extensions: [
customExtensions.value.of(toValue(extensions)),
readOnlyExtensions.value.of([
EditorState.readOnly.of(toValue(isReadOnly)),
EditorView.editable.of(!toValue(isReadOnly)),
]),
readOnlyExtensions.value.of([EditorState.readOnly.of(toValue(isReadOnly))]),
telemetryExtensions.value.of([]),
EditorView.updateListener.of(onEditorUpdate),
EditorView.focusChangeEffect.of((_, newHasFocus) => {
@ -229,7 +226,6 @@ export const useExpressionEditor = ({
editor.value.dispatch({
effects: readOnlyExtensions.value.reconfigure([
EditorState.readOnly.of(toValue(isReadOnly)),
EditorView.editable.of(!toValue(isReadOnly)),
]),
});
}

View file

@ -6,9 +6,11 @@ import { NodeConnectionType } from 'n8n-workflow';
export function useNodeConnections({
inputs,
outputs,
connections,
}: {
inputs: MaybeRef<CanvasElementData['inputs']>;
outputs: MaybeRef<CanvasElementData['outputs']>;
connections: MaybeRef<CanvasElementData['connections']>;
}) {
/**
* Inputs
@ -26,6 +28,10 @@ export function useNodeConnections({
nonMainInputs.value.filter((input) => input.required),
);
const mainInputConnections = computed(
() => unref(connections).input[NodeConnectionType.Main] ?? [],
);
/**
* Outputs
*/
@ -33,15 +39,22 @@ export function useNodeConnections({
const mainOutputs = computed(() =>
unref(outputs).filter((output) => output.type === NodeConnectionType.Main),
);
const nonMainOutputs = computed(() =>
unref(outputs).filter((output) => output.type !== NodeConnectionType.Main),
);
const mainOutputConnections = computed(
() => unref(connections).output[NodeConnectionType.Main] ?? [],
);
return {
mainInputs,
nonMainInputs,
requiredNonMainInputs,
mainInputConnections,
mainOutputs,
nonMainOutputs,
mainOutputConnections,
};
}

View file

@ -218,7 +218,6 @@ export function resolveParameter<T = IDataObject>(
ExpressionEvaluatorProxy.setEvaluator(
useSettingsStore().settings.expressions?.evaluator ?? 'tmpl',
);
return workflow.expression.getParameterValue(
parameter,
runExecutionData,
@ -342,39 +341,6 @@ function connectionInputData(
}
}
const workflowsStore = useWorkflowsStore();
if (workflowsStore.shouldReplaceInputDataWithPinData) {
const parentPinData = parentNode.reduce<INodeExecutionData[]>((acc, parentNodeName, index) => {
const pinData = workflowsStore.pinDataByNodeName(parentNodeName);
if (pinData) {
acc.push({
json: pinData[0],
pairedItem: {
item: index,
input: 1,
},
});
}
return acc;
}, []);
if (parentPinData.length > 0) {
if (connectionInputData && connectionInputData.length > 0) {
parentPinData.forEach((parentPinDataEntry) => {
connectionInputData![0].json = {
...connectionInputData![0].json,
...parentPinDataEntry.json,
};
});
} else {
connectionInputData = parentPinData;
}
}
}
return connectionInputData;
}

View file

@ -362,7 +362,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
function getCurrentWorkflow(copyData?: boolean): Workflow {
const nodes = getNodes();
const connections = allConnections.value;
const cacheKey = JSON.stringify({ nodes, connections });
const cacheKey = JSON.stringify({ nodes, connections, pinData: pinnedWorkflowData.value });
if (!copyData && cachedWorkflow && cacheKey === cachedWorkflowKey) {
return cachedWorkflow;
}

View file

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
import type { ConnectionTypes, INodeTypeDescription } from 'n8n-workflow';
import type { ConnectionTypes, INodeConnections, INodeTypeDescription } from 'n8n-workflow';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import type { DefaultEdge, Node, NodeProps, Position } from '@vue-flow/core';
import type { INodeUi } from '@/Interface';
@ -25,8 +25,13 @@ export interface CanvasElementData {
id: INodeUi['id'];
type: INodeUi['type'];
typeVersion: INodeUi['typeVersion'];
disabled: INodeUi['disabled'];
inputs: CanvasConnectionPort[];
outputs: CanvasConnectionPort[];
connections: {
input: INodeConnections;
output: INodeConnections;
};
renderType: 'default' | 'trigger' | 'configuration' | 'configurable';
}

View file

@ -95,6 +95,7 @@ const {
revertRenameNode,
setNodeActive,
setNodeSelected,
toggleNodeDisabled,
deleteNode,
revertDeleteNode,
addNodes,
@ -363,6 +364,14 @@ function onRevertDeleteNode({ node }: { node: INodeUi }) {
revertDeleteNode(node);
}
function onToggleNodeDisabled(id: string) {
if (!checkIfEditingIsAllowed()) {
return;
}
toggleNodeDisabled(id);
}
function onSetNodeActive(id: string) {
setNodeActive(id);
}
@ -680,6 +689,7 @@ onBeforeUnmount(() => {
@update:node:position="onUpdateNodePosition"
@update:node:active="onSetNodeActive"
@update:node:selected="onSetNodeSelected"
@update:node:enabled="onToggleNodeDisabled"
@delete:node="onDeleteNode"
@create:connection="onCreateConnection"
@delete:connection="onDeleteConnection"

View file

@ -3743,6 +3743,8 @@ export default defineComponent({
}
},
async initView(): Promise<void> {
await this.loadCredentialsForWorkflow();
if (this.$route.params.action === 'workflowSave') {
// In case the workflow got saved we do not have to run init
// as only the route changed but all the needed data is already loaded
@ -3820,7 +3822,7 @@ export default defineComponent({
await this.newWorkflow();
}
}
await this.loadCredentials();
this.historyStore.reset();
this.uiStore.nodeViewInitialized = true;
document.addEventListener('keydown', this.keyDown);
@ -4468,12 +4470,13 @@ export default defineComponent({
async loadCredentialTypes(): Promise<void> {
await this.credentialsStore.fetchCredentialTypes(true);
},
async loadCredentials(): Promise<void> {
async loadCredentialsForWorkflow(): Promise<void> {
const workflow = this.workflowsStore.getWorkflowById(this.currentWorkflow);
const workflowId = workflow?.id ?? this.$route.params.name;
let options: { workflowId: string } | { projectId: string };
if (workflow) {
options = { workflowId: workflow.id };
if (workflowId) {
options = { workflowId };
} else {
const queryParam =
typeof this.$route.query?.projectId === 'string'
@ -4801,7 +4804,7 @@ export default defineComponent({
await Promise.all([
this.loadVariables(),
this.tagsStore.fetchAll(),
this.loadCredentials(),
this.loadCredentialsForWorkflow(),
]);
} catch (error) {
console.error(error);

View file

@ -1,6 +1,6 @@
{
"name": "n8n-node-dev",
"version": "1.47.0",
"version": "1.48.0",
"description": "CLI to simplify n8n credentials/node development",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -48,7 +48,7 @@
"@types/inquirer": "^6.5.0"
},
"dependencies": {
"@oclif/core": "3.26.6",
"@oclif/core": "4.0.7",
"change-case": "^4.1.1",
"fast-glob": "^3.2.5",
"inquirer": "^7.0.1",

View file

@ -37,7 +37,6 @@ export async function calApiRequest(
try {
return await this.helpers.httpRequestWithAuthentication.call(this, 'calApi', options);
} catch (error) {
if (error instanceof NodeApiError) throw error;
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}

View file

@ -435,7 +435,6 @@ export class Coda implements INodeType {
});
continue;
}
if (error instanceof NodeApiError) throw error;
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}
@ -803,7 +802,6 @@ export class Coda implements INodeType {
});
continue;
}
if (error instanceof NodeApiError) throw error;
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}

View file

@ -67,6 +67,7 @@ const versionDescription: INodeTypeDescription = {
displayName: 'Email Trigger (IMAP)',
name: 'emailReadImap',
icon: 'fa:inbox',
iconColor: 'green',
group: ['trigger'],
version: 2,
description: 'Triggers the workflow when a new email is received',

View file

@ -276,6 +276,11 @@ export class GetResponse implements INodeType {
if (updateFields.customFieldsUi) {
const customFieldValues = (updateFields.customFieldsUi as IDataObject)
.customFieldValues as IDataObject[];
customFieldValues.forEach((entry) => {
if (typeof entry.value === 'string') {
entry.value = entry.value.split(',').map((value) => value.trim());
}
});
if (customFieldValues) {
body.customFieldValues = customFieldValues;
delete body.customFieldsUi;

View file

@ -57,8 +57,6 @@ export async function googleApiRequest(
);
}
} catch (error) {
if (error instanceof NodeApiError) throw error;
if (error.code === 'ERR_OSSL_PEM_NO_START_LINE') {
error.statusCode = '401';
}

View file

@ -1869,8 +1869,20 @@ export class HttpRequestV3 implements INodeType {
if (autoDetectResponseFormat && responseData.reason.error instanceof Buffer) {
responseData.reason.error = Buffer.from(responseData.reason.error as Buffer).toString();
}
const error = new NodeApiError(this.getNode(), responseData as JsonObject, { itemIndex });
let error;
if (responseData?.reason instanceof NodeApiError) {
error = responseData.reason;
set(error, 'context.itemIndex', itemIndex);
} else {
const errorData = (
responseData.reason ? responseData.reason : responseData
) as JsonObject;
error = new NodeApiError(this.getNode(), errorData, { itemIndex });
}
set(error, 'context.request', sanitizedRequests[itemIndex]);
throw error;
} else {
removeCircularRefs(responseData.reason as JsonObject);

View file

@ -27,7 +27,6 @@ export async function uprocApiRequest(
try {
return await this.helpers.httpRequestWithAuthentication.call(this, 'uprocApi', options);
} catch (error) {
if (error instanceof NodeApiError) throw error;
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}

View file

@ -1,6 +1,6 @@
{
"name": "n8n-nodes-base",
"version": "1.47.0",
"version": "1.48.0",
"description": "Base nodes of n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",

View file

@ -1,6 +1,6 @@
{
"name": "n8n-workflow",
"version": "1.46.0",
"version": "1.47.0",
"description": "Workflow base code of n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",

View file

@ -310,7 +310,7 @@ type ICredentialHttpRequestNode = {
export interface ICredentialType {
name: string;
displayName: string;
icon?: Themed<Icon>;
icon?: Icon;
iconUrl?: Themed<string>;
extends?: string[];
properties: INodeProperties[];
@ -1571,13 +1571,15 @@ export type NodeIconColor =
| 'azure'
| 'purple'
| 'crimson';
export type Icon = `fa:${string}` | `file:${string}` | `node:${string}`;
export type Themed<T> = T | { light: T; dark: T };
export type IconRef = `fa:${string}` | `node:${string}.${string}`;
export type IconFile = `file:${string}.png` | `file:${string}.svg`;
export type Icon = IconRef | Themed<IconFile>;
export interface INodeTypeBaseDescription {
displayName: string;
name: string;
icon?: Themed<Icon>;
icon?: Icon;
iconColor?: NodeIconColor;
iconUrl?: Themed<string>;
badgeIconUrl?: Themed<string>;

View file

@ -63,6 +63,18 @@ function dedupe<T>(arr: T[]): T[] {
return [...new Set(arr)];
}
export interface WorkflowParameters {
id?: string;
name?: string;
nodes: INode[];
connections: IConnections;
active: boolean;
nodeTypes: INodeTypes;
staticData?: IDataObject;
settings?: IWorkflowSettings;
pinData?: IPinData;
}
export class Workflow {
id: string;
@ -90,18 +102,7 @@ export class Workflow {
pinData?: IPinData;
// constructor(id: string | undefined, nodes: INode[], connections: IConnections, active: boolean, nodeTypes: INodeTypes, staticData?: IDataObject, settings?: IWorkflowSettings) {
constructor(parameters: {
id?: string;
name?: string;
nodes: INode[];
connections: IConnections;
active: boolean;
nodeTypes: INodeTypes;
staticData?: IDataObject;
settings?: IWorkflowSettings;
pinData?: IPinData;
}) {
constructor(parameters: WorkflowParameters) {
this.id = parameters.id as string; // @tech_debt Ensure this is not optional
this.name = parameters.name;
this.nodeTypes = parameters.nodeTypes;
@ -251,16 +252,14 @@ export class Workflow {
* is fine. If there are issues it returns the issues
* which have been found for the different nodes.
* TODO: Does currently not check for credential issues!
*
*/
checkReadyForExecution(inputData: {
checkReadyForExecution(
inputData: {
startNode?: string;
destinationNode?: string;
pinDataNodeNames?: string[];
}): IWorkflowIssues | null {
let node: INode;
let nodeType: INodeType | undefined;
let nodeIssues: INodeIssues | null = null;
} = {},
): IWorkflowIssues | null {
const workflowIssues: IWorkflowIssues = {};
let checkNodes: string[] = [];
@ -277,14 +276,14 @@ export class Workflow {
}
for (const nodeName of checkNodes) {
nodeIssues = null;
node = this.nodes[nodeName];
let nodeIssues: INodeIssues | null = null;
const node = this.nodes[nodeName];
if (node.disabled === true) {
continue;
}
nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
if (nodeType === undefined) {
// Node type is not known
@ -415,7 +414,7 @@ export class Workflow {
*
* @param {string} nodeName Name of the node to return the pinData of
*/
getPinDataOfNode(nodeName: string): IDataObject[] | undefined {
getPinDataOfNode(nodeName: string): INodeExecutionData[] | undefined {
return this.pinData ? this.pinData[nodeName] : undefined;
}

View file

@ -29,6 +29,7 @@ import { deepCopy } from './utils';
import { getGlobalState } from './GlobalState';
import { ApplicationError } from './errors/application.error';
import { SCRIPTING_NODE_TYPES } from './Constants';
import { getPinDataIfManualExecution } from './WorkflowDataProxyHelpers';
export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator {
return Boolean(
@ -241,6 +242,29 @@ export class WorkflowDataProxy {
});
}
private getNodeExecutionOrPinnedData({
nodeName,
branchIndex,
runIndex,
shortSyntax = false,
}: {
nodeName: string;
branchIndex?: number;
runIndex?: number;
shortSyntax?: boolean;
}) {
try {
return this.getNodeExecutionData(nodeName, shortSyntax, branchIndex, runIndex);
} catch (e) {
const pinData = getPinDataIfManualExecution(this.workflow, nodeName, this.mode);
if (pinData) {
return pinData;
}
throw e;
}
}
/**
* Returns the node ExecutionData
*
@ -283,7 +307,7 @@ export class WorkflowDataProxy {
if (
!that.runExecutionData.resultData.runData.hasOwnProperty(nodeName) &&
!that.workflow.getPinDataOfNode(nodeName)
!getPinDataIfManualExecution(that.workflow, nodeName, that.mode)
) {
throw new ExpressionError('Referenced node is unexecuted', {
runIndex: that.runIndex,
@ -383,7 +407,10 @@ export class WorkflowDataProxy {
}
if (['binary', 'data', 'json'].includes(name)) {
const executionData = that.getNodeExecutionData(nodeName, shortSyntax, undefined);
const executionData = that.getNodeExecutionOrPinnedData({
nodeName,
shortSyntax,
});
if (executionData.length === 0) {
if (that.workflow.getParentNodes(nodeName).length === 0) {
@ -619,11 +646,6 @@ export class WorkflowDataProxy {
getDataProxy(): IWorkflowDataProxyData {
const that = this;
const getNodeOutput = (nodeName: string, branchIndex: number, runIndex?: number) => {
runIndex = runIndex === undefined ? -1 : runIndex;
return that.getNodeExecutionData(nodeName, false, branchIndex, runIndex);
};
// replacing proxies with the actual data.
const jmespathWrapper = (data: IDataObject | IDataObject[], query: string) => {
if (typeof data !== 'object' || typeof query !== 'string') {
@ -662,7 +684,7 @@ export class WorkflowDataProxy {
if (context?.nodeCause) {
const nodeName = context.nodeCause;
const pinData = this.workflow.getPinDataOfNode(nodeName);
const pinData = getPinDataIfManualExecution(that.workflow, nodeName, that.mode);
if (pinData) {
if (!context) {
@ -776,7 +798,8 @@ export class WorkflowDataProxy {
const previousNodeOutputData =
taskData?.data?.main?.[previousNodeOutput] ??
(that.workflow.getPinDataOfNode(sourceData.previousNode) as INodeExecutionData[]);
getPinDataIfManualExecution(that.workflow, sourceData.previousNode, that.mode) ??
[];
const source = taskData?.source ?? [];
if (pairedItem.item >= previousNodeOutputData.length) {
@ -897,10 +920,22 @@ export class WorkflowDataProxy {
}
taskData =
that.runExecutionData!.resultData.runData[sourceData.previousNode][
that.runExecutionData!.resultData.runData[sourceData.previousNode]?.[
sourceData?.previousNodeRun || 0
];
if (!taskData) {
const pinData = getPinDataIfManualExecution(
that.workflow,
sourceData.previousNode,
that.mode,
);
if (pinData) {
taskData = { data: { main: [pinData] }, startTime: 0, executionTime: 0, source: [] };
}
}
const previousNodeOutput = sourceData.previousNodeOutput || 0;
if (previousNodeOutput >= taskData.data!.main.length) {
throw createExpressionError('Cant get data for expression', {
@ -944,7 +979,7 @@ export class WorkflowDataProxy {
const ensureNodeExecutionData = () => {
if (
!that?.runExecutionData?.resultData?.runData.hasOwnProperty(nodeName) &&
!that.workflow.getPinDataOfNode(nodeName)
!getPinDataIfManualExecution(that.workflow, nodeName, that.mode)
) {
throw createExpressionError('Referenced node is unexecuted', {
runIndex: that.runIndex,
@ -1009,8 +1044,20 @@ export class WorkflowDataProxy {
itemIndex = that.itemIndex;
}
if (!that.connectionInputData.length) {
const pinnedData = getPinDataIfManualExecution(
that.workflow,
nodeName,
that.mode,
);
if (pinnedData) {
return pinnedData[itemIndex];
}
}
const executionData = that.connectionInputData;
const input = executionData[itemIndex];
const input = executionData?.[itemIndex];
if (!input) {
throw createExpressionError('Cant get data for expression', {
messageTemplate: 'Cant get data for expression under %%PARAMETER%% field',
@ -1061,6 +1108,7 @@ export class WorkflowDataProxy {
}
return pairedItemMethod;
}
if (property === 'first') {
ensureNodeExecutionData();
return (branchIndex?: number, runIndex?: number) => {
@ -1070,7 +1118,11 @@ export class WorkflowDataProxy {
that.workflow.getNodeConnectionIndexes(that.activeNodeName, nodeName)
?.sourceIndex ??
0;
const executionData = getNodeOutput(nodeName, branchIndex, runIndex);
const executionData = that.getNodeExecutionOrPinnedData({
nodeName,
branchIndex,
runIndex,
});
if (executionData[0]) return executionData[0];
return undefined;
};
@ -1084,7 +1136,11 @@ export class WorkflowDataProxy {
that.workflow.getNodeConnectionIndexes(that.activeNodeName, nodeName)
?.sourceIndex ??
0;
const executionData = getNodeOutput(nodeName, branchIndex, runIndex);
const executionData = that.getNodeExecutionOrPinnedData({
nodeName,
branchIndex,
runIndex,
});
if (!executionData.length) return undefined;
if (executionData[executionData.length - 1]) {
return executionData[executionData.length - 1];
@ -1101,7 +1157,7 @@ export class WorkflowDataProxy {
that.workflow.getNodeConnectionIndexes(that.activeNodeName, nodeName)
?.sourceIndex ??
0;
return getNodeOutput(nodeName, branchIndex, runIndex);
return that.getNodeExecutionOrPinnedData({ nodeName, branchIndex, runIndex });
};
}
if (property === 'context') {

View file

@ -0,0 +1,12 @@
import type { INodeExecutionData, Workflow, WorkflowExecuteMode } from '.';
export function getPinDataIfManualExecution(
workflow: Workflow,
nodeName: string,
mode: WorkflowExecuteMode,
): INodeExecutionData[] | undefined {
if (mode !== 'manual') {
return undefined;
}
return workflow.getPinDataOfNode(nodeName);
}

View file

@ -1,4 +1,4 @@
import { isTraversableObject } from '../../utils';
import { isTraversableObject, jsonParse } from '../../utils';
import type { IDataObject, INode, JsonObject } from '@/Interfaces';
import { ExecutionBaseError } from './execution-base.error';
@ -81,9 +81,16 @@ export abstract class NodeError extends ExecutionBaseError {
traversalKeys: string[] = [],
): string | null {
for (const key of potentialKeys) {
const value = jsonError[key];
let value = jsonError[key];
if (value) {
if (typeof value === 'string') {
try {
value = jsonParse(value);
} catch (error) {
return value as string;
}
if (typeof value === 'string') return value;
}
if (typeof value === 'number') return value.toString();
if (Array.isArray(value)) {
const resolvedErrors: string[] = value

View file

@ -39,8 +39,9 @@ interface NodeApiErrorOptions extends NodeOperationErrorOptions {
/**
* Top-level properties where an error message can be found in an API response.
* order is important, precedence is from top to bottom
*/
const ERROR_MESSAGE_PROPERTIES = [
const POSSIBLE_ERROR_MESSAGE_KEYS = [
'cause',
'error',
'message',
@ -60,6 +61,7 @@ const ERROR_MESSAGE_PROPERTIES = [
'errorDescription',
'error_description',
'error_summary',
'error_info',
'title',
'text',
'field',
@ -67,10 +69,15 @@ const ERROR_MESSAGE_PROPERTIES = [
'type',
];
/**
* Properties where a nested object can be found in an API response.
*/
const POSSIBLE_NESTED_ERROR_OBJECT_KEYS = ['Error', 'error', 'err', 'response', 'body', 'data'];
/**
* Top-level properties where an HTTP error code can be found in an API response.
*/
const ERROR_STATUS_PROPERTIES = [
const POSSIBLE_ERROR_STATUS_KEYS = [
'statusCode',
'status',
'code',
@ -79,11 +86,6 @@ const ERROR_STATUS_PROPERTIES = [
'error_code',
];
/**
* Properties where a nested object can be found in an API response.
*/
const ERROR_NESTING_PROPERTIES = ['error', 'err', 'response', 'body', 'data'];
/**
* Descriptive messages for common HTTP status codes
* this is used by NodeApiError class
@ -187,7 +189,11 @@ export class NodeApiError extends NodeError {
this.httpCode = errorResponse.httpCode as string;
} else {
this.httpCode =
this.findProperty(errorResponse, ERROR_STATUS_PROPERTIES, ERROR_NESTING_PROPERTIES) ?? null;
this.findProperty(
errorResponse,
POSSIBLE_ERROR_STATUS_KEYS,
POSSIBLE_NESTED_ERROR_OBJECT_KEYS,
) ?? null;
}
this.level = level ?? 'warning';
@ -222,8 +228,8 @@ export class NodeApiError extends NodeError {
} else {
this.description = this.findProperty(
errorResponse,
ERROR_MESSAGE_PROPERTIES,
ERROR_NESTING_PROPERTIES,
POSSIBLE_ERROR_MESSAGE_KEYS,
POSSIBLE_NESTED_ERROR_OBJECT_KEYS,
);
}
}
@ -266,8 +272,8 @@ export class NodeApiError extends NodeError {
const topLevelKey = Object.keys(result)[0];
this.description = this.findProperty(
result[topLevelKey],
ERROR_MESSAGE_PROPERTIES,
['Error'].concat(ERROR_NESTING_PROPERTIES),
POSSIBLE_ERROR_MESSAGE_KEYS,
POSSIBLE_NESTED_ERROR_OBJECT_KEYS,
);
});
}

View file

@ -571,6 +571,37 @@ const setNode: LoadedClass<INodeType> = {
},
};
const manualTriggerNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
description: {
displayName: 'Manual Trigger',
name: 'n8n-nodes-base.manualTrigger',
icon: 'fa:mouse-pointer',
group: ['trigger'],
version: 1,
description: 'Runs the flow on clicking a button in n8n',
eventTriggerDescription: '',
maxNodes: 1,
defaults: {
name: 'When clicking Test workflow',
color: '#909298',
},
inputs: [],
outputs: ['main'],
properties: [
{
displayName:
'This node is where the workflow execution starts (when you click the test button on the canvas).<br><br> <a data-action="showNodeCreator">Explore other ways to trigger your workflow</a> (e.g on a schedule, or a webhook)',
name: 'notice',
type: 'notice',
default: '',
},
],
},
},
};
export class NodeTypes implements INodeTypes {
nodeTypes: INodeTypeData = {
'n8n-nodes-base.stickyNote': stickyNode,
@ -628,6 +659,7 @@ export class NodeTypes implements INodeTypes {
},
},
},
'n8n-nodes-base.manualTrigger': manualTriggerNode,
};
getByName(nodeType: string): INodeType | IVersionedNodeType {

View file

@ -1,3 +1,5 @@
import { mock } from 'jest-mock-extended';
import { NodeConnectionType } from '@/Interfaces';
import type {
IBinaryKeyData,
IConnections,
@ -5,10 +7,14 @@ import type {
INode,
INodeExecutionData,
INodeParameters,
INodeType,
INodeTypeDescription,
INodeTypes,
IRunExecutionData,
NodeParameterValueType,
} from '@/Interfaces';
import { Workflow } from '@/Workflow';
import { Workflow, type WorkflowParameters } from '@/Workflow';
import * as NodeHelpers from '@/NodeHelpers';
process.env.TEST_VARIABLE_1 = 'valueEnvVariable1';
@ -20,7 +26,128 @@ interface StubNode {
}
describe('Workflow', () => {
describe('renameNodeInParameterValue for expressions', () => {
describe('checkIfWorkflowCanBeActivated', () => {
const disabledNode = mock<INode>({ type: 'triggerNode', disabled: true });
const unknownNode = mock<INode>({ type: 'unknownNode' });
const noTriggersNode = mock<INode>({ type: 'noTriggersNode' });
const pollNode = mock<INode>({ type: 'pollNode' });
const triggerNode = mock<INode>({ type: 'triggerNode' });
const webhookNode = mock<INode>({ type: 'webhookNode' });
const nodeTypes = mock<INodeTypes>();
nodeTypes.getByNameAndVersion.mockImplementation((type) => {
// TODO: getByNameAndVersion signature needs to be updated to allow returning undefined
if (type === 'unknownNode') return undefined as unknown as INodeType;
const partial: Partial<INodeType> = {
poll: undefined,
trigger: undefined,
webhook: undefined,
description: mock<INodeTypeDescription>({
properties: [],
}),
};
if (type === 'pollNode') partial.poll = jest.fn();
if (type === 'triggerNode') partial.trigger = jest.fn();
if (type === 'webhookNode') partial.webhook = jest.fn();
return mock(partial);
});
test.each([
['should skip disabled nodes', disabledNode, [], false],
['should skip nodes marked as ignored', triggerNode, ['triggerNode'], false],
['should skip unknown nodes', unknownNode, [], false],
['should skip nodes with no trigger method', noTriggersNode, [], false],
['should activate if poll method exists', pollNode, [], true],
['should activate if trigger method exists', triggerNode, [], true],
['should activate if webhook method exists', webhookNode, [], true],
])('%s', async (_, node, ignoredNodes, expected) => {
const params = mock<WorkflowParameters>({ nodeTypes });
params.nodes = [node];
const workflow = new Workflow(params);
expect(workflow.checkIfWorkflowCanBeActivated(ignoredNodes)).toBe(expected);
});
});
describe('checkReadyForExecution', () => {
const disabledNode = mock<INode>({ name: 'Disabled Node', disabled: true });
const startNode = mock<INode>({ name: 'Start Node' });
const unknownNode = mock<INode>({ name: 'Unknown Node', type: 'unknownNode' });
const nodeParamIssuesSpy = jest.spyOn(NodeHelpers, 'getNodeParametersIssues');
const nodeTypes = mock<INodeTypes>();
nodeTypes.getByNameAndVersion.mockImplementation((type) => {
// TODO: getByNameAndVersion signature needs to be updated to allow returning undefined
if (type === 'unknownNode') return undefined as unknown as INodeType;
return mock<INodeType>({
description: {
properties: [],
},
});
});
beforeEach(() => jest.clearAllMocks());
it('should return null if there are no nodes', () => {
const workflow = new Workflow({
nodes: [],
connections: {},
active: false,
nodeTypes,
});
const issues = workflow.checkReadyForExecution();
expect(issues).toBe(null);
expect(nodeTypes.getByNameAndVersion).not.toHaveBeenCalled();
expect(nodeParamIssuesSpy).not.toHaveBeenCalled();
});
it('should return null if there are no enabled nodes', () => {
const workflow = new Workflow({
nodes: [disabledNode],
connections: {},
active: false,
nodeTypes,
});
const issues = workflow.checkReadyForExecution({ startNode: disabledNode.name });
expect(issues).toBe(null);
expect(nodeTypes.getByNameAndVersion).toHaveBeenCalledTimes(1);
expect(nodeParamIssuesSpy).not.toHaveBeenCalled();
});
it('should return typeUnknown for unknown nodes', () => {
const workflow = new Workflow({
nodes: [unknownNode],
connections: {},
active: false,
nodeTypes,
});
const issues = workflow.checkReadyForExecution({ startNode: unknownNode.name });
expect(issues).toEqual({ [unknownNode.name]: { typeUnknown: true } });
expect(nodeTypes.getByNameAndVersion).toHaveBeenCalledTimes(2);
expect(nodeParamIssuesSpy).not.toHaveBeenCalled();
});
it('should return issues for regular nodes', () => {
const workflow = new Workflow({
nodes: [startNode],
connections: {},
active: false,
nodeTypes,
});
nodeParamIssuesSpy.mockReturnValue({ execution: false });
const issues = workflow.checkReadyForExecution({ startNode: startNode.name });
expect(issues).toEqual({ [startNode.name]: { execution: false } });
expect(nodeTypes.getByNameAndVersion).toHaveBeenCalledTimes(2);
expect(nodeParamIssuesSpy).toHaveBeenCalled();
});
});
describe('renameNodeInParameterValue', () => {
describe('for expressions', () => {
const tests = [
{
description: 'do nothing if there is no expression',
@ -81,7 +208,8 @@ describe('Workflow', () => {
},
output: {
value1: '={{$("NewName")["data"]["value1"] + \'Node1\'}}',
value2: '={{$("NewName")["data"]["value2"] + \' - \' + $("NewName")["data"]["value2"]}}',
value2:
'={{$("NewName")["data"]["value2"] + \' - \' + $("NewName")["data"]["value2"]}}',
},
},
{
@ -267,7 +395,7 @@ describe('Workflow', () => {
}
});
describe('renameNodeInParameterValue for node with renamable content', () => {
describe('for node with renamable content', () => {
const tests = [
{
description: "should work with $('name')",
@ -318,6 +446,7 @@ describe('Workflow', () => {
});
}
});
});
describe('renameNode', () => {
const tests = [
@ -377,7 +506,7 @@ describe('Workflow', () => {
[
{
node: 'Node2',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -408,7 +537,7 @@ describe('Workflow', () => {
[
{
node: 'Node2',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -444,7 +573,7 @@ describe('Workflow', () => {
[
{
node: 'Node2',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -475,7 +604,7 @@ describe('Workflow', () => {
[
{
node: 'Node2New',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -532,7 +661,7 @@ describe('Workflow', () => {
[
{
node: 'Node3',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -543,12 +672,12 @@ describe('Workflow', () => {
[
{
node: 'Node3',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
{
node: 'Node5',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -559,12 +688,12 @@ describe('Workflow', () => {
[
{
node: 'Node4',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
{
node: 'Node5',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -616,7 +745,7 @@ describe('Workflow', () => {
[
{
node: 'Node3New',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -627,12 +756,12 @@ describe('Workflow', () => {
[
{
node: 'Node3New',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
{
node: 'Node5',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -643,12 +772,12 @@ describe('Workflow', () => {
[
{
node: 'Node4',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
{
node: 'Node5',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -1229,7 +1358,7 @@ describe('Workflow', () => {
[
{
node: 'Node2',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -1240,7 +1369,7 @@ describe('Workflow', () => {
[
{
node: 'Node3',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -1251,7 +1380,7 @@ describe('Workflow', () => {
[
{
node: 'Node2',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -1521,7 +1650,7 @@ describe('Workflow', () => {
[
{
node: 'Set',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -1532,7 +1661,7 @@ describe('Workflow', () => {
[
{
node: 'Set1',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -1591,21 +1720,21 @@ describe('Workflow', () => {
[
{
node: 'Set1',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
[
{
node: 'Set',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
[
{
node: 'Set',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -1616,7 +1745,7 @@ describe('Workflow', () => {
[
{
node: 'Set2',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -1627,7 +1756,7 @@ describe('Workflow', () => {
[
{
node: 'Set2',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -1691,7 +1820,7 @@ describe('Workflow', () => {
[
{
node: 'Set',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -1699,7 +1828,7 @@ describe('Workflow', () => {
[
{
node: 'Switch',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -1710,7 +1839,7 @@ describe('Workflow', () => {
[
{
node: 'Set1',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -1721,12 +1850,12 @@ describe('Workflow', () => {
[
{
node: 'Set1',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
{
node: 'Switch',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -1737,7 +1866,7 @@ describe('Workflow', () => {
[
{
node: 'Set1',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],

View file

@ -1,4 +1,11 @@
import type { IExecuteData, INode, IRun, IWorkflowBase } from '@/Interfaces';
import type {
IExecuteData,
INode,
IPinData,
IRun,
IWorkflowBase,
WorkflowExecuteMode,
} from '@/Interfaces';
import { Workflow } from '@/Workflow';
import { WorkflowDataProxy } from '@/WorkflowDataProxy';
import { ExpressionError } from '@/errors/expression.error';
@ -13,7 +20,12 @@ const loadFixture = (fixture: string) => {
return { workflow, run };
};
const getProxyFromFixture = (workflow: IWorkflowBase, run: IRun | null, activeNode: string) => {
const getProxyFromFixture = (
workflow: IWorkflowBase,
run: IRun | null,
activeNode: string,
mode?: WorkflowExecuteMode,
) => {
const taskData = run?.data.resultData.runData[activeNode]?.[0];
const lastNodeConnectionInputData = taskData?.data?.main[0];
@ -29,6 +41,16 @@ const getProxyFromFixture = (workflow: IWorkflowBase, run: IRun | null, activeNo
};
}
let pinData: IPinData = {};
if (workflow.pinData) {
// json key is stored as part of workflow
// but dropped when copy/pasting
// so adding here to keep updating tests simple
for (let nodeName in workflow.pinData) {
pinData[nodeName] = workflow.pinData[nodeName].map((item) => ({ json: item }));
}
}
const dataProxy = new WorkflowDataProxy(
new Workflow({
id: '123',
@ -37,6 +59,7 @@ const getProxyFromFixture = (workflow: IWorkflowBase, run: IRun | null, activeNo
connections: workflow.connections,
active: false,
nodeTypes: Helpers.NodeTypes(),
pinData,
}),
run?.data ?? null,
0,
@ -44,7 +67,7 @@ const getProxyFromFixture = (workflow: IWorkflowBase, run: IRun | null, activeNo
activeNode,
lastNodeConnectionInputData ?? [],
{},
'manual',
mode ?? 'integrated',
{},
executeData,
);
@ -323,4 +346,61 @@ describe('WorkflowDataProxy', () => {
}
});
});
describe('Pinned data with manual execution', () => {
const fixture = loadFixture('pindata');
const proxy = getProxyFromFixture(fixture.workflow, null, 'NotPinnedSet1', 'manual');
test('$(PinnedSet).item.json', () => {
expect(proxy.$('PinnedSet').item.json).toEqual({ firstName: 'Joe', lastName: 'Smith' });
});
test('$(PinnedSet).item.json.firstName', () => {
expect(proxy.$('PinnedSet').item.json.firstName).toBe('Joe');
});
test('$(PinnedSet).pairedItem().json.firstName', () => {
expect(proxy.$('PinnedSet').pairedItem().json.firstName).toBe('Joe');
});
test('$(PinnedSet).first().json.firstName', () => {
expect(proxy.$('PinnedSet').first().json.firstName).toBe('Joe');
});
test('$(PinnedSet).first().json.firstName', () => {
expect(proxy.$('PinnedSet').first().json.firstName).toBe('Joe');
});
test('$(PinnedSet).last().json.firstName', () => {
expect(proxy.$('PinnedSet').last().json.firstName).toBe('Joan');
});
test('$(PinnedSet).all()[0].json.firstName', () => {
expect(proxy.$('PinnedSet').all()[0].json.firstName).toBe('Joe');
});
test('$(PinnedSet).all()[1].json.firstName', () => {
expect(proxy.$('PinnedSet').all()[1].json.firstName).toBe('Joan');
});
test('$(PinnedSet).all()[2]', () => {
expect(proxy.$('PinnedSet').all()[2]).toBeUndefined();
});
test('$(PinnedSet).itemMatching(0).json.firstName', () => {
expect(proxy.$('PinnedSet').itemMatching(0).json.firstName).toBe('Joe');
});
test('$(PinnedSet).itemMatching(1).json.firstName', () => {
expect(proxy.$('PinnedSet').itemMatching(1).json.firstName).toBe('Joan');
});
test('$(PinnedSet).itemMatching(2)', () => {
expect(proxy.$('PinnedSet').itemMatching(2)).toBeUndefined();
});
test('$node[PinnedSet].json.firstName', () => {
expect(proxy.$node.PinnedSet.json.firstName).toBe('Joe');
});
});
});

View file

@ -0,0 +1,7 @@
{
"data": {
"resultData": {
"runData": {}
}
}
}

View file

@ -0,0 +1,106 @@
{
"meta": {
"instanceId": "a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0"
},
"nodes": [
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "3058c300-b377-41b7-9c90-a01372f9b581",
"name": "firstName",
"value": "Joe",
"type": "string"
},
{
"id": "bb871662-c23c-4234-ac0c-b78c279bbf34",
"name": "lastName",
"value": "Smith",
"type": "string"
}
]
},
"options": {}
},
"id": "baee2bf4-5083-4cbe-8e51-4eddcf859ef5",
"name": "PinnedSet",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [
1120,
380
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "a482f1fd-4815-4da4-a733-7beafb43c500",
"name": "test",
"value": "={{ $('PinnedSet').all().json }}\n{{ $('PinnedSet').item.json.firstName }}\n{{ $('PinnedSet').first().json.firstName }}\n{{ $('PinnedSet').itemMatching(0).json.firstName }}\n{{ $('PinnedSet').itemMatching(1).json.firstName }}\n{{ $('PinnedSet').last().json.firstName }}\n{{ $('PinnedSet').all()[0].json.firstName }}\n{{ $('PinnedSet').all()[1].json.firstName }}\n\n{{ $input.first().json.firstName }}\n{{ $input.last().json.firstName }}\n{{ $input.item.json.firstName }}\n\n{{ $json.firstName }}\n{{ $data.firstName }}\n\n{{ $items()[0].json.firstName }}",
"type": "string"
}
]
},
"options": {}
},
"id": "2a543169-e2c1-4764-ac63-09534310b2b9",
"name": "NotPinnedSet1",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [
1360,
380
]
},
{
"parameters": {},
"id": "f36672e5-8c87-480e-a5b8-de9da6b63192",
"name": "Start",
"type": "n8n-nodes-base.manualTrigger",
"position": [
920,
380
],
"typeVersion": 1
}
],
"connections": {
"PinnedSet": {
"main": [
[
{
"node": "NotPinnedSet1",
"type": "main",
"index": 0
}
]
]
},
"Start": {
"main": [
[
{
"node": "PinnedSet",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"PinnedSet": [
{
"firstName": "Joe",
"lastName": "Smith"
},
{
"firstName": "Joan",
"lastName": "Summers"
}
]
}
}

View file

@ -554,8 +554,8 @@ importers:
specifier: 2.13.0
version: 2.13.0
'@oclif/core':
specifier: 3.26.6
version: 3.26.6
specifier: 4.0.7
version: 4.0.7
'@pinecone-database/pinecone':
specifier: 2.1.0
version: 2.1.0
@ -1323,8 +1323,8 @@ importers:
packages/node-dev:
dependencies:
'@oclif/core':
specifier: 3.26.6
version: 3.26.6
specifier: 4.0.7
version: 4.0.7
change-case:
specifier: ^4.1.1
version: 4.1.2
@ -4165,8 +4165,8 @@ packages:
engines: {node: '>=10'}
deprecated: This functionality has been moved to @npmcli/fs
'@oclif/core@3.26.6':
resolution: {integrity: sha512-+FiTw1IPuJTF9tSAlTsY8bGK4sgthehjz7c2SvYdgQncTkxI2xvUch/8QpjNYGLEmUneNygvYMRBax2KJcLccA==}
'@oclif/core@4.0.7':
resolution: {integrity: sha512-sU4Dx+RXCWAkrMw8tQFYAL6VfcHYKLPxVC9iKfgTXr4aDhcCssDwrbgpx0Di1dnNxvQlDGUhuCEInZuIY/nNfw==}
engines: {node: '>=18.0.0'}
'@one-ini/wasm@0.1.1':
@ -5420,9 +5420,6 @@ packages:
'@types/cheerio@0.22.31':
resolution: {integrity: sha512-Kt7Cdjjdi2XWSfrZ53v4Of0wG3ZcmaegFXjMmz9tfNrZSkzzo36G0AL1YqSdcIA78Etjt6E609pt5h1xnQkPUw==}
'@types/cli-progress@3.11.5':
resolution: {integrity: sha512-D4PbNRbviKyppS5ivBGyFO29POlySLmA2HyUFE4p5QGazAMM3CwkKWcvTl8gvElSuxRh6FPKL8XmidX873ou4g==}
'@types/compression@1.0.1':
resolution: {integrity: sha512-GuoIYzD70h+4JUqUabsm31FGqvpCYHGKcLtor7nQ/YvUyNX0o9SJZ9boFI5HjFfbOda5Oe/XOvNK6FES8Y/79w==}
@ -6305,8 +6302,9 @@ packages:
resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
engines: {node: '>=12'}
ansicolors@0.3.2:
resolution: {integrity: sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==}
ansis@3.2.0:
resolution: {integrity: sha512-Yk3BkHH9U7oPyCN3gL5Tc7CpahG/+UFv/6UG03C311Vy9lzRmA5uoxDTpU9CO3rGHL6KzJz/pdDeXZCZ5Mu/Sg==}
engines: {node: '>=15'}
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
@ -6789,10 +6787,6 @@ packages:
capital-case@1.0.4:
resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==}
cardinal@2.1.1:
resolution: {integrity: sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==}
hasBin: true
caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
@ -6923,14 +6917,14 @@ packages:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'}
cli-progress@3.12.0:
resolution: {integrity: sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==}
engines: {node: '>=4'}
cli-spinners@2.9.0:
resolution: {integrity: sha512-4/aL9X3Wh0yiMQlE+eeRhWP6vclO3QRtw1JHKIT0FFUs5FjpFmESqtMvYZ0+lbzBw900b95mS0hohy+qn2VK/g==}
engines: {node: '>=6'}
cli-spinners@2.9.2:
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
engines: {node: '>=6'}
cli-table3@0.6.3:
resolution: {integrity: sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==}
engines: {node: 10.* || >= 12.*}
@ -7002,10 +6996,6 @@ packages:
color@3.2.1:
resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==}
color@4.2.3:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'}
colord@2.9.3:
resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==}
@ -7434,6 +7424,15 @@ packages:
supports-color:
optional: true
debug@4.3.5:
resolution: {integrity: sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
@ -8839,10 +8838,6 @@ packages:
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
hyperlinker@1.0.0:
resolution: {integrity: sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ==}
engines: {node: '>=4'}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@ -9852,6 +9847,10 @@ packages:
resolution: {integrity: sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==}
engines: {node: '>=14'}
lilconfig@3.1.2:
resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==}
engines: {node: '>=14'}
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@ -10496,9 +10495,6 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
natural-orderby@2.0.3:
resolution: {integrity: sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==}
negotiator@0.6.3:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
@ -10721,10 +10717,6 @@ packages:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
engines: {node: '>= 0.4'}
object-treeify@1.1.33:
resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==}
engines: {node: '>= 10'}
object.assign@4.1.4:
resolution: {integrity: sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==}
engines: {node: '>= 0.4'}
@ -10930,9 +10922,6 @@ packages:
pascal-case@3.1.2:
resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==}
password-prompt@1.1.3:
resolution: {integrity: sha512-HkrjG2aJlvF0t2BMH0e2LB/EHf3Lcq3fNMzy4GYHcQblAvOl+QQji1Lx7WRBMqpVK8p+KR7bCg7oqAMXtdgqyw==}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
@ -11750,9 +11739,6 @@ packages:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
redeyed@2.1.1:
resolution: {integrity: sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==}
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
@ -12548,10 +12534,6 @@ packages:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
supports-hyperlinks@2.3.0:
resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==}
engines: {node: '>=8'}
supports-preserve-symlinks-flag@1.0.0:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
@ -17189,33 +17171,22 @@ snapshots:
rimraf: 3.0.2
optional: true
'@oclif/core@3.26.6':
'@oclif/core@4.0.7':
dependencies:
'@types/cli-progress': 3.11.5
ansi-escapes: 4.3.2
ansi-styles: 4.3.0
cardinal: 2.1.1
chalk: 4.1.2
ansis: 3.2.0
clean-stack: 3.0.1
cli-progress: 3.12.0
color: 4.2.3
debug: 4.3.4(supports-color@8.1.1)
cli-spinners: 2.9.2
debug: 4.3.5(supports-color@8.1.1)
ejs: 3.1.10
get-package-type: 0.1.0
globby: 11.1.0
hyperlinker: 1.0.0
indent-string: 4.0.0
is-wsl: 2.2.0
js-yaml: 3.14.1
lilconfig: 3.1.2
minimatch: 9.0.4
natural-orderby: 2.0.3
object-treeify: 1.1.33
password-prompt: 1.1.3
slice-ansi: 4.0.0
string-width: 4.2.3
strip-ansi: 6.0.1
supports-color: 8.1.1
supports-hyperlinks: 2.3.0
widest-line: 3.1.0
wordwrap: 1.0.0
wrap-ansi: 7.0.0
@ -19232,10 +19203,6 @@ snapshots:
dependencies:
'@types/node': 18.16.16
'@types/cli-progress@3.11.5':
dependencies:
'@types/node': 18.16.16
'@types/compression@1.0.1':
dependencies:
'@types/express': 4.17.21
@ -20315,7 +20282,7 @@ snapshots:
ansi-styles@6.2.1: {}
ansicolors@0.3.2: {}
ansis@3.2.0: {}
any-promise@1.3.0: {}
@ -20898,11 +20865,6 @@ snapshots:
tslib: 2.6.2
upper-case-first: 2.0.2
cardinal@2.1.1:
dependencies:
ansicolors: 0.3.2
redeyed: 2.1.1
caseless@0.12.0: {}
chai@4.3.10:
@ -21058,12 +21020,10 @@ snapshots:
dependencies:
restore-cursor: 3.1.0
cli-progress@3.12.0:
dependencies:
string-width: 4.2.3
cli-spinners@2.9.0: {}
cli-spinners@2.9.2: {}
cli-table3@0.6.3:
dependencies:
string-width: 4.2.3
@ -21156,11 +21116,6 @@ snapshots:
color-convert: 1.9.3
color-string: 1.9.1
color@4.2.3:
dependencies:
color-convert: 2.0.1
color-string: 1.9.1
colord@2.9.3: {}
colorette@1.4.0: {}
@ -21652,6 +21607,12 @@ snapshots:
optionalDependencies:
supports-color: 8.1.1
debug@4.3.5(supports-color@8.1.1):
dependencies:
ms: 2.1.2
optionalDependencies:
supports-color: 8.1.1
decamelize@1.2.0: {}
decimal.js@10.4.3: {}
@ -23463,8 +23424,6 @@ snapshots:
dependencies:
ms: 2.1.3
hyperlinker@1.0.0: {}
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@ -24675,6 +24634,8 @@ snapshots:
lilconfig@3.0.0: {}
lilconfig@3.1.2: {}
lines-and-columns@1.2.4: {}
linkify-it@3.0.3:
@ -25334,8 +25295,6 @@ snapshots:
natural-compare@1.4.0: {}
natural-orderby@2.0.3: {}
negotiator@0.6.3: {}
neo-async@2.6.2: {}
@ -25576,8 +25535,6 @@ snapshots:
object-keys@1.1.1: {}
object-treeify@1.1.33: {}
object.assign@4.1.4:
dependencies:
call-bind: 1.0.7
@ -25828,11 +25785,6 @@ snapshots:
no-case: 3.0.4
tslib: 2.6.2
password-prompt@1.1.3:
dependencies:
ansi-escapes: 4.3.2
cross-spawn: 7.0.3
path-browserify@1.0.1: {}
path-case@3.0.4:
@ -26686,10 +26638,6 @@ snapshots:
indent-string: 4.0.0
strip-indent: 3.0.0
redeyed@2.1.1:
dependencies:
esprima: 4.0.1
redis-errors@1.2.0: {}
redis-parser@3.0.0:
@ -27699,11 +27647,6 @@ snapshots:
dependencies:
has-flag: 4.0.0
supports-hyperlinks@2.3.0:
dependencies:
has-flag: 4.0.0
supports-color: 7.2.0
supports-preserve-symlinks-flag@1.0.0: {}
svgo@3.1.0: