Merge master

This commit is contained in:
Iván Ovejero 2024-09-25 18:14:16 +02:00
commit 309932f461
No known key found for this signature in database
113 changed files with 5000 additions and 2095 deletions

View file

@ -128,19 +128,19 @@ jobs:
- name: Trigger a release note - name: Trigger a release note
run: curl -u docsWorkflows:${{ secrets.N8N_WEBHOOK_DOCS_PASSWORD }} --request GET 'https://internal.users.n8n.cloud/webhook/trigger-release-note' --header 'Content-Type:application/json' --data '{"version":"${{ needs.publish-to-npm.outputs.release }}"}' run: curl -u docsWorkflows:${{ secrets.N8N_WEBHOOK_DOCS_PASSWORD }} --request GET 'https://internal.users.n8n.cloud/webhook/trigger-release-note' --header 'Content-Type:application/json' --data '{"version":"${{ needs.publish-to-npm.outputs.release }}"}'
merge-back-into-master: # merge-back-into-master:
name: Merge back into master # name: Merge back into master
needs: [publish-to-npm, create-github-release] # needs: [publish-to-npm, create-github-release]
if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'release:patch') }} # if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'release:patch') }}
runs-on: ubuntu-latest # runs-on: ubuntu-latest
steps: # steps:
- uses: actions/checkout@v4.1.1 # - uses: actions/checkout@v4.1.1
with: # with:
fetch-depth: 0 # fetch-depth: 0
- run: | # - run: |
git checkout --track origin/master # git checkout --track origin/master
git config user.name "github-actions[bot]" # git config user.name "github-actions[bot]"
git config user.email 41898282+github-actions[bot]@users.noreply.github.com # git config user.email 41898282+github-actions[bot]@users.noreply.github.com
git merge --ff n8n@${{ needs.publish-to-npm.outputs.release }} # git merge --ff n8n@${{ needs.publish-to-npm.outputs.release }}
git push origin master # git push origin master
git push origin :${{github.event.pull_request.base.ref}} # git push origin :${{github.event.pull_request.base.ref}}

View file

@ -1,3 +1,46 @@
# [1.61.0](https://github.com/n8n-io/n8n/compare/n8n@1.60.0...n8n@1.61.0) (2024-09-25)
### Bug Fixes
* **core:** Add executionData to expressions in pagination code ([#10926](https://github.com/n8n-io/n8n/issues/10926)) ([eac103e](https://github.com/n8n-io/n8n/commit/eac103e367d59a532b9ba12db78a0dd10aee62fb))
* **core:** Fix webhook binary data max size configuration ([#10897](https://github.com/n8n-io/n8n/issues/10897)) ([693fb7e](https://github.com/n8n-io/n8n/commit/693fb7e580b7e030c86977bff6d319bbee4fcd62))
* **core:** Remove subworkflow license check ([#10893](https://github.com/n8n-io/n8n/issues/10893)) ([0290e38](https://github.com/n8n-io/n8n/commit/0290e38f990275074eb7e7ccd0b41f1ae0215dd2))
* **editor:** Credentials scopes and n8n scopes mix up ([#10930](https://github.com/n8n-io/n8n/issues/10930)) ([e069608](https://github.com/n8n-io/n8n/commit/e0696080227aee7ccb50d51a82873e8a1ba4667d))
* **editor:** Fix design system form component sizing ([#10961](https://github.com/n8n-io/n8n/issues/10961)) ([cf153ea](https://github.com/n8n-io/n8n/commit/cf153ea085165115ee523fbb1bd32080dde47eda))
* **editor:** Fix modal overflow when AI is enabled in code node ([#10887](https://github.com/n8n-io/n8n/issues/10887)) ([f9f303f](https://github.com/n8n-io/n8n/commit/f9f303f562084db8c8956da267680b1f935aa2df))
* **editor:** Fix source control push modal checkboxes ([#10910](https://github.com/n8n-io/n8n/issues/10910)) ([8db8817](https://github.com/n8n-io/n8n/commit/8db88178511749b19a5878816ef062092fd9f2be))
* **editor:** Fix styling and typography in AI Assistant chat ([#10895](https://github.com/n8n-io/n8n/issues/10895)) ([57ff3cc](https://github.com/n8n-io/n8n/commit/57ff3cc27b9470bfbe2486c3c1831c57f5a4075f))
* **editor:** Prevent clipboard xss injection ([#10894](https://github.com/n8n-io/n8n/issues/10894)) ([e20ab59](https://github.com/n8n-io/n8n/commit/e20ab59c1dcf9da19a30268ce19930bfa7e38992))
* **editor:** Prevent node name input in NDV to expand unnecessarily ([#10922](https://github.com/n8n-io/n8n/issues/10922)) ([a2237d1](https://github.com/n8n-io/n8n/commit/a2237d128ff6a4d65cd30325b6b9d9b765ca7be6))
* **editor:** Update gird size when opening credentials support chat ([#10882](https://github.com/n8n-io/n8n/issues/10882)) ([b86fd80](https://github.com/n8n-io/n8n/commit/b86fd80fc9fe06011367ca04a75e4b52533db1fe))
* **editor:** Use `:focus-visible` instead for `:focus` for buttons ([#10921](https://github.com/n8n-io/n8n/issues/10921)) ([bf28d09](https://github.com/n8n-io/n8n/commit/bf28d0965c46620a106c87037bafd2cf936f1050))
* **editor:** Use correct output for connected nodes in schema view ([#10928](https://github.com/n8n-io/n8n/issues/10928)) ([ad60d49](https://github.com/n8n-io/n8n/commit/ad60d49b4251138a7c69cb5e9f00c3ef875486e0))
* Enable Assistant on other credential views ([#10931](https://github.com/n8n-io/n8n/issues/10931)) ([557db9c](https://github.com/n8n-io/n8n/commit/557db9c170a89447ec9cc14aa1af51e5fd11dd92))
* Ensure user id for early track events ([#10885](https://github.com/n8n-io/n8n/issues/10885)) ([23c09ea](https://github.com/n8n-io/n8n/commit/23c09eae4223545c717270a5cd305d2e57e1ad5b))
* **Google Sheets Node:** Insert data if sheet is empty instead of error ([#10942](https://github.com/n8n-io/n8n/issues/10942)) ([c75990e](https://github.com/n8n-io/n8n/commit/c75990e0632c581384542610a886ef89621a9403))
* Hide assistant button when showing Click to connect ([#10932](https://github.com/n8n-io/n8n/issues/10932)) ([d74cff2](https://github.com/n8n-io/n8n/commit/d74cff20301f285588f93207f29660d25fdbc8da))
* **HTTP Request Node:** Do not modify request object when sanitizing message for UI ([#10923](https://github.com/n8n-io/n8n/issues/10923)) ([8cc10cc](https://github.com/n8n-io/n8n/commit/8cc10cc2c1869b9abcafd157e41be65ce2b6f499))
* **MQTT Node:** Close connection if connection attempt fails ([#10873](https://github.com/n8n-io/n8n/issues/10873)) ([ee7147c](https://github.com/n8n-io/n8n/commit/ee7147c6b3b053ac8fc317319ab257204e599f16))
* **MySQL Node:** Fix "Maximum call stack size exceeded" error when handling a large number of rows ([#10965](https://github.com/n8n-io/n8n/issues/10965)) ([62159bd](https://github.com/n8n-io/n8n/commit/62159bd71c9a0303b597a68113e0ac50473ee8d4))
* **Notion Node:** Allow UUID v8 in notion id checks ([#10938](https://github.com/n8n-io/n8n/issues/10938)) ([46beda0](https://github.com/n8n-io/n8n/commit/46beda05f6771c31bcf0b6a781976d8261079a66))
### Features
* **Brandfetch Node:** Update to use new API ([#10877](https://github.com/n8n-io/n8n/issues/10877)) ([08ba9a3](https://github.com/n8n-io/n8n/commit/08ba9a36a43b6c84f69bb04fa4d6419a7a4adddf))
* **editor:** Setup Sentry integration ([#10945](https://github.com/n8n-io/n8n/issues/10945)) ([6de4dff](https://github.com/n8n-io/n8n/commit/6de4dfff87e4da888567081a9928d9682bdea11d))
* **editor:** Show a notice before deleting annotated executions ([#10934](https://github.com/n8n-io/n8n/issues/10934)) ([dcc1c72](https://github.com/n8n-io/n8n/commit/dcc1c72fc4b56c3252183541b22da801804d4f79))
* Page size 1 option ([#10957](https://github.com/n8n-io/n8n/issues/10957)) ([bdc0622](https://github.com/n8n-io/n8n/commit/bdc0622f59e98c9e6c542f5cb59a2dbd9008ba96))
* **Slack Node:** Add option to hide workflow link on message update ([#10927](https://github.com/n8n-io/n8n/issues/10927)) ([422c946](https://github.com/n8n-io/n8n/commit/422c9463c8d931a728615a1fe5a10f05a96ecaa2))
### Performance Improvements
* **editor:** Use virtual scrolling in `RunDataJson.vue` ([#10838](https://github.com/n8n-io/n8n/issues/10838)) ([f5474ff](https://github.com/n8n-io/n8n/commit/f5474ff79198a2f5a145d0a9df1bb651ea677ec5))
# [1.60.0](https://github.com/n8n-io/n8n/compare/n8n@1.59.0...n8n@1.60.0) (2024-09-18) # [1.60.0](https://github.com/n8n-io/n8n/compare/n8n@1.59.0...n8n@1.60.0) (2024-09-18)

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-monorepo", "name": "n8n-monorepo",
"version": "1.60.0", "version": "1.61.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=20.15", "node": ">=20.15",
@ -81,7 +81,7 @@
}, },
"patchedDependencies": { "patchedDependencies": {
"typedi@0.10.0": "patches/typedi@0.10.0.patch", "typedi@0.10.0": "patches/typedi@0.10.0.patch",
"@sentry/cli@2.17.0": "patches/@sentry__cli@2.17.0.patch", "@sentry/cli@2.36.2": "patches/@sentry__cli@2.36.2.patch",
"pkce-challenge@3.0.0": "patches/pkce-challenge@3.0.0.patch", "pkce-challenge@3.0.0": "patches/pkce-challenge@3.0.0.patch",
"pyodide@0.23.4": "patches/pyodide@0.23.4.patch", "pyodide@0.23.4": "patches/pyodide@0.23.4.patch",
"@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch", "@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch",

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/api-types", "name": "@n8n/api-types",
"version": "0.2.0", "version": "0.3.0",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/n8n-benchmark", "name": "@n8n/n8n-benchmark",
"version": "1.4.0", "version": "1.5.0",
"description": "Cli for running benchmark tests for n8n", "description": "Cli for running benchmark tests for n8n",
"main": "dist/index", "main": "dist/index",
"scripts": { "scripts": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/config", "name": "@n8n/config",
"version": "1.10.0", "version": "1.11.0",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View file

@ -0,0 +1,12 @@
import { Config, Env } from '../decorators';
@Config
export class SentryConfig {
/** Sentry DSN for the backend. */
@Env('N8N_SENTRY_DSN')
backendDsn: string = '';
/** Sentry DSN for the frontend . */
@Env('N8N_FRONTEND_SENTRY_DSN')
frontendDsn: string = '';
}

View file

@ -8,6 +8,7 @@ import { ExternalStorageConfig } from './configs/external-storage.config';
import { NodesConfig } from './configs/nodes.config'; import { NodesConfig } from './configs/nodes.config';
import { PublicApiConfig } from './configs/public-api.config'; import { PublicApiConfig } from './configs/public-api.config';
import { ScalingModeConfig } from './configs/scaling-mode.config'; import { ScalingModeConfig } from './configs/scaling-mode.config';
import { SentryConfig } from './configs/sentry.config';
import { TemplatesConfig } from './configs/templates.config'; import { TemplatesConfig } from './configs/templates.config';
import { UserManagementConfig } from './configs/user-management.config'; import { UserManagementConfig } from './configs/user-management.config';
import { VersionNotificationsConfig } from './configs/version-notifications.config'; import { VersionNotificationsConfig } from './configs/version-notifications.config';
@ -49,6 +50,9 @@ export class GlobalConfig {
@Nested @Nested
workflows: WorkflowsConfig; workflows: WorkflowsConfig;
@Nested
sentry: SentryConfig;
/** Path n8n is deployed to */ /** Path n8n is deployed to */
@Env('N8N_PATH') @Env('N8N_PATH')
path: string = '/'; path: string = '/';

View file

@ -221,6 +221,10 @@ describe('GlobalConfig', () => {
}, },
}, },
}, },
sentry: {
backendDsn: '',
frontendDsn: '',
},
}; };
it('should use all default values when no env variables are defined', () => { it('should use all default values when no env variables are defined', () => {

View file

@ -85,7 +85,6 @@ function getInputs(
'@n8n/n8n-nodes-langchain.lmChatGroq', '@n8n/n8n-nodes-langchain.lmChatGroq',
'@n8n/n8n-nodes-langchain.lmChatOllama', '@n8n/n8n-nodes-langchain.lmChatOllama',
'@n8n/n8n-nodes-langchain.lmChatOpenAi', '@n8n/n8n-nodes-langchain.lmChatOpenAi',
'@n8n/n8n-nodes-langchain.lmChatGooglePalm',
'@n8n/n8n-nodes-langchain.lmChatGoogleGemini', '@n8n/n8n-nodes-langchain.lmChatGoogleGemini',
'@n8n/n8n-nodes-langchain.lmChatGoogleVertex', '@n8n/n8n-nodes-langchain.lmChatGoogleVertex',
'@n8n/n8n-nodes-langchain.lmChatMistralCloud', '@n8n/n8n-nodes-langchain.lmChatMistralCloud',
@ -111,11 +110,13 @@ function getInputs(
nodes: [ nodes: [
'@n8n/n8n-nodes-langchain.lmChatAnthropic', '@n8n/n8n-nodes-langchain.lmChatAnthropic',
'@n8n/n8n-nodes-langchain.lmChatAzureOpenAi', '@n8n/n8n-nodes-langchain.lmChatAzureOpenAi',
'@n8n/n8n-nodes-langchain.lmChatAwsBedrock',
'@n8n/n8n-nodes-langchain.lmChatMistralCloud', '@n8n/n8n-nodes-langchain.lmChatMistralCloud',
'@n8n/n8n-nodes-langchain.lmChatOllama', '@n8n/n8n-nodes-langchain.lmChatOllama',
'@n8n/n8n-nodes-langchain.lmChatOpenAi', '@n8n/n8n-nodes-langchain.lmChatOpenAi',
'@n8n/n8n-nodes-langchain.lmChatGroq', '@n8n/n8n-nodes-langchain.lmChatGroq',
'@n8n/n8n-nodes-langchain.lmChatGoogleVertex', '@n8n/n8n-nodes-langchain.lmChatGoogleVertex',
'@n8n/n8n-nodes-langchain.lmChatGoogleGemini',
], ],
}, },
}, },

View file

@ -1,4 +1,5 @@
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */
import { BedrockEmbeddings } from '@langchain/aws';
import { import {
NodeConnectionType, NodeConnectionType,
type IExecuteFunctions, type IExecuteFunctions,
@ -6,7 +7,6 @@ import {
type INodeTypeDescription, type INodeTypeDescription,
type SupplyData, type SupplyData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { BedrockEmbeddings } from '@langchain/community/embeddings/bedrock';
import { logWrapper } from '../../../utils/logWrapper'; import { logWrapper } from '../../../utils/logWrapper';
import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; import { getConnectionHintNoticeField } from '../../../utils/sharedFields';

View file

@ -1,135 +0,0 @@
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
import {
NodeConnectionType,
type IExecuteFunctions,
type INodeType,
type INodeTypeDescription,
type SupplyData,
} from 'n8n-workflow';
import { GooglePaLMEmbeddings } from '@langchain/community/embeddings/googlepalm';
import { logWrapper } from '../../../utils/logWrapper';
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
export class EmbeddingsGooglePalm implements INodeType {
description: INodeTypeDescription = {
displayName: 'Embeddings Google PaLM',
name: 'embeddingsGooglePalm',
icon: 'file:google.svg',
group: ['transform'],
version: 1,
description: 'Use Google PaLM Embeddings',
defaults: {
name: 'Embeddings Google PaLM',
},
requestDefaults: {
ignoreHttpStatusErrors: true,
baseURL: '={{ $credentials.host }}',
},
credentials: [
{
name: 'googlePalmApi',
required: true,
},
],
codex: {
categories: ['AI'],
subcategories: {
AI: ['Embeddings'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.embeddingsgooglepalm/',
},
],
},
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [],
// eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong
outputs: [NodeConnectionType.AiEmbedding],
outputNames: ['Embeddings'],
properties: [
getConnectionHintNoticeField([NodeConnectionType.AiVectorStore]),
{
displayName:
'Each model is using different dimensional density for embeddings. Please make sure to use the same dimensionality for your vector store. The default model is using 768-dimensional embeddings.',
name: 'notice',
type: 'notice',
default: '',
},
{
displayName: 'Model',
name: 'modelName',
type: 'options',
description:
'The model which will generate the embeddings. <a href="https://developers.generativeai.google/api/rest/generativelanguage/models/list">Learn more</a>.',
typeOptions: {
loadOptions: {
routing: {
request: {
method: 'GET',
url: '/v1beta3/models',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'models',
},
},
{
type: 'filter',
properties: {
pass: "={{ $responseItem.name.startsWith('models/embedding') }}",
},
},
{
type: 'setKeyValue',
properties: {
name: '={{$responseItem.name}}',
value: '={{$responseItem.name}}',
description: '={{$responseItem.description}}',
},
},
{
type: 'sort',
properties: {
key: 'name',
},
},
],
},
},
},
},
routing: {
send: {
type: 'body',
property: 'model',
},
},
default: 'models/embedding-gecko-001',
},
],
};
async supplyData(this: IExecuteFunctions, itemIndex: number): Promise<SupplyData> {
this.logger.debug('Supply data for embeddings Google PaLM');
const modelName = this.getNodeParameter(
'modelName',
itemIndex,
'models/embedding-gecko-001',
) as string;
const credentials = await this.getCredentials('googlePalmApi');
const embeddings = new GooglePaLMEmbeddings({
apiKey: credentials.apiKey as string,
modelName,
});
return {
response: logWrapper(embeddings, this),
};
}
}

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4"/></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"/></clipPath><path fill="#FBBC05" d="M0 37V11l17 13z" clip-path="url(#b)"/><path fill="#EA4335" d="m0 11 17 13 7-6.1L48 14V0H0z" clip-path="url(#b)"/><path fill="#34A853" d="m0 37 30-23 7.9 1L48 0v48H0z" clip-path="url(#b)"/><path fill="#4285F4" d="M48 48 17 24l-4-3 35-10z" clip-path="url(#b)"/></svg>

Before

Width:  |  Height:  |  Size: 687 B

View file

@ -1,4 +1,5 @@
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */
import { ChatBedrockConverse } from '@langchain/aws';
import { import {
NodeConnectionType, NodeConnectionType,
type IExecuteFunctions, type IExecuteFunctions,
@ -6,13 +7,8 @@ import {
type INodeTypeDescription, type INodeTypeDescription,
type SupplyData, type SupplyData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { BedrockChat } from '@langchain/community/chat_models/bedrock';
import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
// Dependencies needed underneath the hood. We add them
// here only to track where what dependency is used
import '@aws-sdk/credential-provider-node';
import '@aws-sdk/client-bedrock-runtime';
import '@aws-sdk/client-sso-oidc';
import { N8nLlmTracing } from '../N8nLlmTracing'; import { N8nLlmTracing } from '../N8nLlmTracing';
export class LmChatAwsBedrock implements INodeType { export class LmChatAwsBedrock implements INodeType {
@ -144,7 +140,7 @@ export class LmChatAwsBedrock implements INodeType {
maxTokensToSample: number; maxTokensToSample: number;
}; };
const model = new BedrockChat({ const model = new ChatBedrockConverse({
region: credentials.region as string, region: credentials.region as string,
model: modelName, model: modelName,
temperature: options.temperature, temperature: options.temperature,

View file

@ -1,173 +0,0 @@
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
import {
NodeConnectionType,
type IExecuteFunctions,
type INodeType,
type INodeTypeDescription,
type SupplyData,
} from 'n8n-workflow';
import { ChatGooglePaLM } from '@langchain/community/chat_models/googlepalm';
import { N8nLlmTracing } from '../N8nLlmTracing';
export class LmChatGooglePalm implements INodeType {
description: INodeTypeDescription = {
displayName: 'Google PaLM Chat Model',
// eslint-disable-next-line n8n-nodes-base/node-class-description-name-miscased
name: 'lmChatGooglePalm',
icon: 'file:google.svg',
hidden: true,
group: ['transform'],
version: 1,
description: 'Chat Model Google PaLM',
defaults: {
name: 'Google PaLM Chat Model',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Language Models', 'Root Nodes'],
'Language Models': ['Chat Models (Recommended)'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatgooglepalm/',
},
],
},
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [],
// eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong
outputs: [NodeConnectionType.AiLanguageModel],
outputNames: ['Model'],
credentials: [
{
name: 'googlePalmApi',
required: true,
},
],
requestDefaults: {
ignoreHttpStatusErrors: true,
baseURL: '={{ $credentials.host }}',
},
properties: [
{
displayName:
"Google PaLM API is <a href='https://ai.google.dev/palm_docs/deprecation' target='_blank'>deprecated</a>. Please use Google Vertex or Google Gemini nodes instead.",
name: 'deprecated',
type: 'notice',
default: '',
},
{
displayName: 'Model',
name: 'modelName',
type: 'options',
description:
'The model which will generate the completion. <a href="https://developers.generativeai.google/api/rest/generativelanguage/models/list">Learn more</a>.',
typeOptions: {
loadOptions: {
routing: {
request: {
method: 'GET',
url: '/v1beta3/models',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'models',
},
},
{
type: 'filter',
properties: {
pass: "={{ $responseItem.name.startsWith('models/chat') }}",
},
},
{
type: 'setKeyValue',
properties: {
name: '={{$responseItem.name}}',
value: '={{$responseItem.name}}',
description: '={{$responseItem.description}}',
},
},
{
type: 'sort',
properties: {
key: 'name',
},
},
],
},
},
},
},
routing: {
send: {
type: 'body',
property: 'model',
},
},
default: 'models/chat-bison-001',
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
description: 'Additional options to add',
type: 'collection',
default: {},
options: [
{
displayName: 'Sampling Temperature',
name: 'temperature',
default: 0.7,
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
description:
'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.',
type: 'number',
},
{
displayName: 'Top K',
name: 'topK',
default: 40,
typeOptions: { maxValue: 1, minValue: -1, numberPrecision: 1 },
description:
'Used to remove "long tail" low probability responses. Defaults to -1, which disables it.',
type: 'number',
},
{
displayName: 'Top P',
name: 'topP',
default: 0.9,
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
description:
'Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered. We generally recommend altering this or temperature but not both.',
type: 'number',
},
],
},
],
};
async supplyData(this: IExecuteFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('googlePalmApi');
const modelName = this.getNodeParameter('modelName', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as object;
const model = new ChatGooglePaLM({
apiKey: credentials.apiKey as string,
modelName,
...options,
callbacks: [new N8nLlmTracing(this)],
});
return {
response: model,
};
}
}

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4"/></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"/></clipPath><path fill="#FBBC05" d="M0 37V11l17 13z" clip-path="url(#b)"/><path fill="#EA4335" d="m0 11 17 13 7-6.1L48 14V0H0z" clip-path="url(#b)"/><path fill="#34A853" d="m0 37 30-23 7.9 1L48 0v48H0z" clip-path="url(#b)"/><path fill="#4285F4" d="M48 48 17 24l-4-3 35-10z" clip-path="url(#b)"/></svg>

Before

Width:  |  Height:  |  Size: 687 B

View file

@ -1,180 +0,0 @@
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
import {
NodeConnectionType,
type IExecuteFunctions,
type INodeType,
type INodeTypeDescription,
type SupplyData,
} from 'n8n-workflow';
import { GooglePaLM } from '@langchain/community/llms/googlepalm';
import { N8nLlmTracing } from '../N8nLlmTracing';
export class LmGooglePalm implements INodeType {
description: INodeTypeDescription = {
displayName: 'Google PaLM Language Model',
// eslint-disable-next-line n8n-nodes-base/node-class-description-name-miscased
name: 'lmGooglePalm',
hidden: true,
icon: 'file:google.svg',
group: ['transform'],
version: 1,
description: 'Language Model Google PaLM',
defaults: {
name: 'Google PaLM Language Model',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Language Models', 'Root Nodes'],
'Language Models': ['Text Completion Models'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmgooglepalm/',
},
],
},
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [],
// eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong
outputs: [NodeConnectionType.AiLanguageModel],
outputNames: ['Model'],
credentials: [
{
name: 'googlePalmApi',
required: true,
},
],
requestDefaults: {
ignoreHttpStatusErrors: true,
baseURL: '={{ $credentials.host }}',
},
properties: [
{
displayName:
"Google PaLM API is <a href='https://ai.google.dev/palm_docs/deprecation' target='_blank'>deprecated</a>. Please use Google Vertex or Google Gemini nodes instead.",
name: 'deprecated',
type: 'notice',
default: '',
},
{
displayName: 'Model',
name: 'modelName',
type: 'options',
description:
'The model which will generate the completion. <a href="https://developers.generativeai.google/api/rest/generativelanguage/models/list">Learn more</a>.',
typeOptions: {
loadOptions: {
routing: {
request: {
method: 'GET',
url: '/v1beta3/models',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'models',
},
},
{
type: 'filter',
properties: {
pass: "={{ $responseItem.name.startsWith('models/text') }}",
},
},
{
type: 'setKeyValue',
properties: {
name: '={{$responseItem.name}}',
value: '={{$responseItem.name}}',
description: '={{$responseItem.description}}',
},
},
{
type: 'sort',
properties: {
key: 'name',
},
},
],
},
},
},
},
routing: {
send: {
type: 'body',
property: 'model',
},
},
default: 'models/text-bison-001',
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
description: 'Additional options to add',
type: 'collection',
default: {},
options: [
{
displayName: 'Maximum Number of Tokens',
name: 'maxOutputTokens',
default: 1024,
description: 'The maximum number of tokens to generate in the completion',
type: 'number',
},
{
displayName: 'Sampling Temperature',
name: 'temperature',
default: 0.7,
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
description:
'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.',
type: 'number',
},
{
displayName: 'Top K',
name: 'topK',
default: 40,
typeOptions: { maxValue: 1, minValue: -1, numberPrecision: 1 },
description:
'Used to remove "long tail" low probability responses. Defaults to -1, which disables it.',
type: 'number',
},
{
displayName: 'Top P',
name: 'topP',
default: 0.9,
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
description:
'Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered. We generally recommend altering this or temperature but not both.',
type: 'number',
},
],
},
],
};
async supplyData(this: IExecuteFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('googlePalmApi');
const modelName = this.getNodeParameter('modelName', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as object;
const model = new GooglePaLM({
apiKey: credentials.apiKey as string,
modelName,
...options,
callbacks: [new N8nLlmTracing(this)],
});
return {
response: model,
};
}
}

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4"/></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"/></clipPath><path fill="#FBBC05" d="M0 37V11l17 13z" clip-path="url(#b)"/><path fill="#EA4335" d="m0 11 17 13 7-6.1L48 14V0H0z" clip-path="url(#b)"/><path fill="#34A853" d="m0 37 30-23 7.9 1L48 0v48H0z" clip-path="url(#b)"/><path fill="#4285F4" d="M48 48 17 24l-4-3 35-10z" clip-path="url(#b)"/></svg>

Before

Width:  |  Height:  |  Size: 687 B

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/n8n-nodes-langchain", "name": "@n8n/n8n-nodes-langchain",
"version": "1.60.0", "version": "1.61.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@ -57,7 +57,6 @@
"dist/nodes/embeddings/EmbeddingsCohere/EmbeddingsCohere.node.js", "dist/nodes/embeddings/EmbeddingsCohere/EmbeddingsCohere.node.js",
"dist/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.js", "dist/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.js",
"dist/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.js", "dist/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.js",
"dist/nodes/embeddings/EmbeddingsGooglePalm/EmbeddingsGooglePalm.node.js",
"dist/nodes/embeddings/EmbeddingsGoogleGemini/EmbeddingsGoogleGemini.node.js", "dist/nodes/embeddings/EmbeddingsGoogleGemini/EmbeddingsGoogleGemini.node.js",
"dist/nodes/embeddings/EmbeddingsHuggingFaceInference/EmbeddingsHuggingFaceInference.node.js", "dist/nodes/embeddings/EmbeddingsHuggingFaceInference/EmbeddingsHuggingFaceInference.node.js",
"dist/nodes/embeddings/EmbeddingsMistralCloud/EmbeddingsMistralCloud.node.js", "dist/nodes/embeddings/EmbeddingsMistralCloud/EmbeddingsMistralCloud.node.js",
@ -65,9 +64,7 @@
"dist/nodes/embeddings/EmbeddingsOllama/EmbeddingsOllama.node.js", "dist/nodes/embeddings/EmbeddingsOllama/EmbeddingsOllama.node.js",
"dist/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.js", "dist/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.js",
"dist/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.js", "dist/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.js",
"dist/nodes/llms/LmGooglePalm/LmGooglePalm.node.js",
"dist/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.js", "dist/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.js",
"dist/nodes/llms/LmChatGooglePalm/LmChatGooglePalm.node.js",
"dist/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.js", "dist/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.js",
"dist/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.js", "dist/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.js",
"dist/nodes/llms/LmChatGroq/LmChatGroq.node.js", "dist/nodes/llms/LmChatGroq/LmChatGroq.node.js",
@ -131,40 +128,38 @@
"n8n-core": "workspace:*" "n8n-core": "workspace:*"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-bedrock-runtime": "3.645.0",
"@aws-sdk/client-sso-oidc": "^3.645.0",
"@aws-sdk/credential-provider-node": "3.645.0",
"@getzep/zep-cloud": "1.0.11", "@getzep/zep-cloud": "1.0.11",
"@getzep/zep-js": "0.9.0", "@getzep/zep-js": "0.9.0",
"@google-ai/generativelanguage": "2.5.0", "@google-ai/generativelanguage": "2.6.0",
"@google-cloud/resource-manager": "5.3.0", "@google-cloud/resource-manager": "5.3.0",
"@google/generative-ai": "0.17.1", "@google/generative-ai": "0.19.0",
"@huggingface/inference": "2.8.0", "@huggingface/inference": "2.8.0",
"@langchain/anthropic": "0.2.16", "@langchain/anthropic": "0.3.1",
"@langchain/cohere": "0.2.2", "@langchain/aws": "^0.1.0",
"@langchain/community": "0.2.32", "@langchain/cohere": "0.3.0",
"@langchain/community": "0.3.2",
"@langchain/core": "catalog:", "@langchain/core": "catalog:",
"@langchain/google-genai": "0.0.26", "@langchain/google-genai": "0.1.0",
"@langchain/google-vertexai": "0.0.27", "@langchain/google-vertexai": "0.1.0",
"@langchain/groq": "0.0.17", "@langchain/groq": "0.1.2",
"@langchain/mistralai": "0.0.29", "@langchain/mistralai": "0.1.1",
"@langchain/ollama": "0.0.4", "@langchain/ollama": "0.1.0",
"@langchain/openai": "0.2.10", "@langchain/openai": "0.3.0",
"@langchain/pinecone": "0.0.9", "@langchain/pinecone": "0.1.0",
"@langchain/qdrant": "0.0.5", "@langchain/qdrant": "0.1.0",
"@langchain/redis": "0.0.5", "@langchain/redis": "0.1.0",
"@langchain/textsplitters": "0.0.3", "@langchain/textsplitters": "0.1.0",
"@mozilla/readability": "^0.5.0", "@mozilla/readability": "^0.5.0",
"@n8n/typeorm": "0.3.20-10", "@n8n/typeorm": "0.3.20-10",
"@n8n/vm2": "3.9.25", "@n8n/vm2": "3.9.25",
"@pinecone-database/pinecone": "3.0.0", "@pinecone-database/pinecone": "3.0.3",
"@qdrant/js-client-rest": "1.9.0", "@qdrant/js-client-rest": "1.11.0",
"@supabase/supabase-js": "2.45.3", "@supabase/supabase-js": "2.45.4",
"@types/pg": "^8.11.6", "@types/pg": "^8.11.6",
"@xata.io/client": "0.28.4", "@xata.io/client": "0.30.0",
"basic-auth": "catalog:", "basic-auth": "catalog:",
"cheerio": "1.0.0-rc.12", "cheerio": "1.0.0-rc.12",
"cohere-ai": "7.13.0", "cohere-ai": "7.13.2",
"d3-dsv": "2.0.0", "d3-dsv": "2.0.0",
"epub2": "3.0.2", "epub2": "3.0.2",
"form-data": "catalog:", "form-data": "catalog:",
@ -172,12 +167,12 @@
"html-to-text": "9.0.5", "html-to-text": "9.0.5",
"jsdom": "^23.0.1", "jsdom": "^23.0.1",
"json-schema-to-zod": "2.1.0", "json-schema-to-zod": "2.1.0",
"langchain": "0.2.18", "langchain": "0.3.2",
"lodash": "catalog:", "lodash": "catalog:",
"mammoth": "1.7.2", "mammoth": "1.7.2",
"n8n-nodes-base": "workspace:*", "n8n-nodes-base": "workspace:*",
"n8n-workflow": "workspace:*", "n8n-workflow": "workspace:*",
"openai": "4.58.0", "openai": "4.63.0",
"pdf-parse": "1.1.1", "pdf-parse": "1.1.1",
"pg": "8.12.0", "pg": "8.12.0",
"redis": "4.6.12", "redis": "4.6.12",
@ -185,6 +180,6 @@
"temp": "0.9.4", "temp": "0.9.4",
"tmp-promise": "3.0.3", "tmp-promise": "3.0.3",
"zod": "catalog:", "zod": "catalog:",
"zod-to-json-schema": "3.23.2" "zod-to-json-schema": "3.23.3"
} }
} }

View file

@ -43,6 +43,13 @@ require('express-async-errors');
require('source-map-support').install(); require('source-map-support').install();
require('reflect-metadata'); require('reflect-metadata');
// Skip loading dotenv in e2e tests.
// Also, do not use `inE2ETests` from constants here, because that'd end up code that might read from `process.env` before the values are loaded from an `.env` file.
if (process.env.E2E_TESTS !== 'true') {
// Loading dotenv early ensures that `process.env` is up-to-date everywhere in code
require('dotenv').config();
}
if (process.env.NODEJS_PREFER_IPV4 === 'true') { if (process.env.NODEJS_PREFER_IPV4 === 'true') {
require('dns').setDefaultResultOrder('ipv4first'); require('dns').setDefaultResultOrder('ipv4first');
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n", "name": "n8n",
"version": "1.60.0", "version": "1.61.0",
"description": "n8n Workflow Automation Tool", "description": "n8n Workflow Automation Tool",
"main": "dist/index", "main": "dist/index",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View file

@ -1,6 +1,5 @@
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import convict from 'convict'; import convict from 'convict';
import dotenv from 'dotenv';
import { flatten } from 'flat'; import { flatten } from 'flat';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import merge from 'lodash/merge'; import merge from 'lodash/merge';
@ -22,8 +21,6 @@ if (inE2ETests) {
process.env.N8N_PUBLIC_API_DISABLED = 'true'; process.env.N8N_PUBLIC_API_DISABLED = 'true';
process.env.SKIP_STATISTICS_EVENTS = 'true'; process.env.SKIP_STATISTICS_EVENTS = 'true';
process.env.N8N_SECURE_COOKIE = 'false'; process.env.N8N_SECURE_COOKIE = 'false';
} else {
dotenv.config();
} }
// Load schema after process.env has been overwritten // Load schema after process.env has been overwritten

View file

@ -452,14 +452,6 @@ export const schema = {
env: 'N8N_DIAGNOSTICS_POSTHOG_API_HOST', env: 'N8N_DIAGNOSTICS_POSTHOG_API_HOST',
}, },
}, },
sentry: {
dsn: {
doc: 'Data source name for error tracking on Sentry',
format: String,
default: '',
env: 'N8N_SENTRY_DSN',
},
},
frontend: { frontend: {
doc: 'Diagnostics config for frontend.', doc: 'Diagnostics config for frontend.',
format: String, format: String,

View file

@ -34,7 +34,7 @@ import { ExternalHooks } from '@/external-hooks';
import { validateEntity } from '@/generic-helpers'; import { validateEntity } from '@/generic-helpers';
import type { ICredentialsDb } from '@/interfaces'; import type { ICredentialsDb } from '@/interfaces';
import { Logger } from '@/logger'; import { Logger } from '@/logger';
import { userHasScope } from '@/permissions/check-access'; import { userHasScopes } from '@/permissions/check-access';
import type { CredentialRequest, ListQuery } from '@/requests'; import type { CredentialRequest, ListQuery } from '@/requests';
import { CredentialsTester } from '@/services/credentials-tester.service'; import { CredentialsTester } from '@/services/credentials-tester.service';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
@ -598,7 +598,7 @@ export class CredentialsService {
// could actually be testing the credential before saving it, so this should cover // could actually be testing the credential before saving it, so this should cover
// the cases we need it for. // the cases we need it for.
if ( if (
!(await userHasScope(user, ['credential:update'], false, { credentialId: credential.id })) !(await userHasScopes(user, ['credential:update'], false, { credentialId: credential.id }))
) { ) {
mergedCredentials.data = decryptedData; mergedCredentials.data = decryptedData;
} }

View file

@ -11,7 +11,7 @@ import { inProduction, RESPONSE_ERROR_MESSAGES } from '@/constants';
import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error';
import type { BooleanLicenseFeature } from '@/interfaces'; import type { BooleanLicenseFeature } from '@/interfaces';
import { License } from '@/license'; import { License } from '@/license';
import { userHasScope } from '@/permissions/check-access'; import { userHasScopes } from '@/permissions/check-access';
import type { AuthenticatedRequest } from '@/requests'; import type { AuthenticatedRequest } from '@/requests';
import { send } from '@/response-helper'; // TODO: move `ResponseHelper.send` to this file import { send } from '@/response-helper'; // TODO: move `ResponseHelper.send` to this file
@ -151,7 +151,7 @@ export class ControllerRegistry {
const { scope, globalOnly } = accessScope; const { scope, globalOnly } = accessScope;
if (!(await userHasScope(req.user, [scope], globalOnly, req.params))) { if (!(await userHasScopes(req.user, [scope], globalOnly, req.params))) {
return res.status(403).json({ return res.status(403).json({
status: 'error', status: 'error',
message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE, message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE,

View file

@ -1,9 +1,9 @@
import { GlobalConfig } from '@n8n/config';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { QueryFailedError } from '@n8n/typeorm'; import { QueryFailedError } from '@n8n/typeorm';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { ErrorReporterProxy, ApplicationError } from 'n8n-workflow'; import { ErrorReporterProxy, ApplicationError } from 'n8n-workflow';
import Container from 'typedi';
import config from '@/config';
let initialized = false; let initialized = false;
@ -14,7 +14,7 @@ export const initErrorHandling = async () => {
ErrorReporterProxy.error(error); ErrorReporterProxy.error(error);
}); });
const dsn = config.getEnv('diagnostics.config.sentry.dsn'); const dsn = Container.get(GlobalConfig).sentry.backendDsn;
if (!dsn) { if (!dsn) {
initialized = true; initialized = true;
return; return;
@ -29,7 +29,7 @@ export const initErrorHandling = async () => {
DEPLOYMENT_NAME: serverName, DEPLOYMENT_NAME: serverName,
} = process.env; } = process.env;
const { init, captureException, addEventProcessor } = await import('@sentry/node'); const { init, captureException } = await import('@sentry/node');
const { RewriteFrames } = await import('@sentry/integrations'); const { RewriteFrames } = await import('@sentry/integrations');
const { Integrations } = await import('@sentry/node'); const { Integrations } = await import('@sentry/node');
@ -41,6 +41,8 @@ export const initErrorHandling = async () => {
'OnUnhandledRejection', 'OnUnhandledRejection',
'ContextLines', 'ContextLines',
]; ];
const seenErrors = new Set<string>();
init({ init({
dsn, dsn,
release, release,
@ -62,34 +64,32 @@ export const initErrorHandling = async () => {
}, },
}), }),
], ],
}); beforeSend(event, { originalException }) {
if (!originalException) return null;
const seenErrors = new Set<string>(); if (
addEventProcessor((event, { originalException }) => { originalException instanceof QueryFailedError &&
if (!originalException) return null; ['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => originalException.message.includes(errMsg))
) {
return null;
}
if ( if (originalException instanceof ApplicationError) {
originalException instanceof QueryFailedError && const { level, extra, tags } = originalException;
['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => originalException.message.includes(errMsg)) if (level === 'warning') return null;
) { event.level = level;
return null; if (extra) event.extra = { ...event.extra, ...extra };
} if (tags) event.tags = { ...event.tags, ...tags };
}
if (originalException instanceof ApplicationError) { if (originalException instanceof Error && originalException.stack) {
const { level, extra, tags } = originalException; const eventHash = createHash('sha1').update(originalException.stack).digest('base64');
if (level === 'warning') return null; if (seenErrors.has(eventHash)) return null;
event.level = level; seenErrors.add(eventHash);
if (extra) event.extra = { ...event.extra, ...extra }; }
if (tags) event.tags = { ...event.tags, ...tags };
}
if (originalException instanceof Error && originalException.stack) { return event;
const eventHash = createHash('sha1').update(originalException.stack).digest('base64'); },
if (seenErrors.has(eventHash)) return null;
seenErrors.add(eventHash);
}
return event;
}); });
ErrorReporterProxy.init({ ErrorReporterProxy.init({

View file

@ -0,0 +1,16 @@
import { Get, RestController } from '@/decorators';
import { AuthenticatedRequest } from '@/requests';
import { EventService } from './event.service';
/** This controller holds endpoints that the frontend uses to trigger telemetry events */
@RestController('/events')
export class EventsController {
constructor(private readonly eventService: EventService) {}
@Get('/session-started')
sessionStarted(req: AuthenticatedRequest) {
const pushRef = req.headers['push-ref'];
this.eventService.emit('session-started', { pushRef });
}
}

View file

@ -10,7 +10,15 @@ import { SharedCredentialsRepository } from '@/databases/repositories/shared-cre
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { RoleService } from '@/services/role.service'; import { RoleService } from '@/services/role.service';
export const userHasScope = async ( /**
* Check if a user has the required scopes. The check can be:
*
* - only for scopes in the user's global role, or
* - for scopes in the user's global role, else for scopes in the resource roles
* of projects including the user and the resource, else for scopes in the
* project roles in those projects.
*/
export async function userHasScopes(
user: User, user: User,
scopes: Scope[], scopes: Scope[],
globalOnly: boolean, globalOnly: boolean,
@ -18,15 +26,14 @@ export const userHasScope = async (
credentialId, credentialId,
workflowId, workflowId,
projectId, projectId,
}: { credentialId?: string; workflowId?: string; projectId?: string }, }: { credentialId?: string; workflowId?: string; projectId?: string } /* only one */,
): Promise<boolean> => { ): Promise<boolean> {
// Short circuit here since a global role will always have access if (user.hasGlobalScope(scopes, { mode: 'allOf' })) return true;
if (user.hasGlobalScope(scopes, { mode: 'allOf' })) {
return true; if (globalOnly) return false;
} else if (globalOnly) {
// The above check already failed so the user doesn't have access // Find which project roles are defined to contain the required scopes.
return false; // Then find projects having this user and having those project roles.
}
const roleService = Container.get(RoleService); const roleService = Container.get(RoleService);
const projectRoles = roleService.rolesWithScope('project', scopes); const projectRoles = roleService.rolesWithScope('project', scopes);
@ -42,47 +49,29 @@ export const userHasScope = async (
}) })
).map((p) => p.id); ).map((p) => p.id);
// Find which resource roles are defined to contain the required scopes.
// Then find at least one of the above qualifying projects having one of
// those resource roles over the resource being checked.
if (credentialId) { if (credentialId) {
const exists = await Container.get(SharedCredentialsRepository).find({ return await Container.get(SharedCredentialsRepository).existsBy({
where: { credentialsId: credentialId,
projectId: In(userProjectIds), projectId: In(userProjectIds),
credentialsId: credentialId, role: In(roleService.rolesWithScope('credential', scopes)),
role: In(roleService.rolesWithScope('credential', scopes)),
},
}); });
if (!exists.length) {
return false;
}
return true;
} }
if (workflowId) { if (workflowId) {
const exists = await Container.get(SharedWorkflowRepository).find({ return await Container.get(SharedWorkflowRepository).existsBy({
where: { workflowId,
projectId: In(userProjectIds), projectId: In(userProjectIds),
workflowId, role: In(roleService.rolesWithScope('workflow', scopes)),
role: In(roleService.rolesWithScope('workflow', scopes)),
},
}); });
if (!exists.length) {
return false;
}
return true;
} }
if (projectId) { if (projectId) return userProjectIds.includes(projectId);
if (!userProjectIds.includes(projectId)) {
return false;
}
return true;
}
throw new ApplicationError( throw new ApplicationError(
"@ProjectScope decorator was used but does not have a credentialId, workflowId, or projectId in it's URL parameters. This is likely an implementation error. If you're a developer, please check you're URL is correct or that this should be using @GlobalScope.", "`@ProjectScope` decorator was used but does not have a `credentialId`, `workflowId`, or `projectId` in its URL parameters. This is likely an implementation error. If you're a developer, please check your URL is correct or that this should be using `@GlobalScope`.",
); );
}; }

View file

@ -6,7 +6,7 @@ import { Container } from 'typedi';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import type { BooleanLicenseFeature } from '@/interfaces'; import type { BooleanLicenseFeature } from '@/interfaces';
import { License } from '@/license'; import { License } from '@/license';
import { userHasScope } from '@/permissions/check-access'; import { userHasScopes } from '@/permissions/check-access';
import type { AuthenticatedRequest } from '@/requests'; import type { AuthenticatedRequest } from '@/requests';
import type { PaginatedRequest } from '../../../types'; import type { PaginatedRequest } from '../../../types';
@ -34,7 +34,7 @@ const buildScopeMiddleware = (
params.credentialId = req.params.id; params.credentialId = req.params.id;
} }
} }
if (!(await userHasScope(req.user, scopes, globalOnly, params))) { if (!(await userHasScopes(req.user, scopes, globalOnly, params))) {
return res.status(403).json({ message: 'Forbidden' }); return res.status(403).json({ message: 'Forbidden' });
} }

View file

@ -49,6 +49,9 @@ export type AuthenticatedRequest<
> = Omit<APIRequest<RouteParams, ResponseBody, RequestBody, RequestQuery>, 'user' | 'cookies'> & { > = Omit<APIRequest<RouteParams, ResponseBody, RequestBody, RequestQuery>, 'user' | 'cookies'> & {
user: User; user: User;
cookies: Record<string, string | undefined>; cookies: Record<string, string | undefined>;
headers: express.Request['headers'] & {
'push-ref': string;
};
}; };
// ---------------------------------- // ----------------------------------

View file

@ -1,4 +1,3 @@
import type { FrontendSettings } from '@n8n/api-types';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import express from 'express'; import express from 'express';
import { access as fsAccess } from 'fs/promises'; import { access as fsAccess } from 'fs/promises';
@ -21,6 +20,7 @@ import {
import { CredentialsOverwrites } from '@/credentials-overwrites'; import { CredentialsOverwrites } from '@/credentials-overwrites';
import { ControllerRegistry } from '@/decorators'; import { ControllerRegistry } from '@/decorators';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { EventService } from '@/events/event.service';
import { LogStreamingEventRelay } from '@/events/log-streaming-event-relay'; import { LogStreamingEventRelay } from '@/events/log-streaming-event-relay';
import type { ICredentialsOverwrite } from '@/interfaces'; import type { ICredentialsOverwrite } from '@/interfaces';
import { isLdapEnabled } from '@/ldap/helpers.ee'; import { isLdapEnabled } from '@/ldap/helpers.ee';
@ -58,12 +58,12 @@ import '@/controllers/user-settings.controller';
import '@/controllers/workflow-statistics.controller'; import '@/controllers/workflow-statistics.controller';
import '@/credentials/credentials.controller'; import '@/credentials/credentials.controller';
import '@/eventbus/event-bus.controller'; import '@/eventbus/event-bus.controller';
import '@/events/events.controller';
import '@/executions/executions.controller'; import '@/executions/executions.controller';
import '@/external-secrets/external-secrets.controller.ee'; import '@/external-secrets/external-secrets.controller.ee';
import '@/license/license.controller'; import '@/license/license.controller';
import '@/workflows/workflow-history/workflow-history.controller.ee'; import '@/workflows/workflow-history/workflow-history.controller.ee';
import '@/workflows/workflows.controller'; import '@/workflows/workflows.controller';
import { EventService } from './events/event.service';
@Service() @Service()
export class Server extends AbstractServer { export class Server extends AbstractServer {
@ -169,10 +169,6 @@ export class Server extends AbstractServer {
const { frontendService } = this; const { frontendService } = this;
if (frontendService) { if (frontendService) {
frontendService.addToSettings({
versionCli: N8N_VERSION,
});
await this.externalHooks.run('frontend.settings', [frontendService.getSettings()]); await this.externalHooks.run('frontend.settings', [frontendService.getSettings()]);
} }
@ -244,11 +240,22 @@ export class Server extends AbstractServer {
// Returns the current settings for the UI // Returns the current settings for the UI
this.app.get( this.app.get(
`/${this.restEndpoint}/settings`, `/${this.restEndpoint}/settings`,
ResponseHelper.send( ResponseHelper.send(async () => frontendService.getSettings()),
async (req: express.Request): Promise<FrontendSettings> =>
frontendService.getSettings(req.headers['push-ref'] as string),
),
); );
// Return Sentry config as a static file
this.app.get(`/${this.restEndpoint}/sentry.js`, (_, res) => {
res.type('js');
res.write('window.sentry=');
res.write(
JSON.stringify({
dsn: this.globalConfig.sentry.frontendDsn,
environment: process.env.ENVIRONMENT || 'development',
release: N8N_VERSION,
}),
);
res.end();
});
} }
// ---------------------------------------- // ----------------------------------------

View file

@ -10,11 +10,10 @@ import path from 'path';
import { Container, Service } from 'typedi'; import { Container, Service } from 'typedi';
import config from '@/config'; import config from '@/config';
import { LICENSE_FEATURES } from '@/constants'; import { LICENSE_FEATURES, N8N_VERSION } from '@/constants';
import { CredentialTypes } from '@/credential-types'; import { CredentialTypes } from '@/credential-types';
import { CredentialsOverwrites } from '@/credentials-overwrites'; import { CredentialsOverwrites } from '@/credentials-overwrites';
import { getVariablesLimit } from '@/environments/variables/environment-helpers'; import { getVariablesLimit } from '@/environments/variables/environment-helpers';
import { EventService } from '@/events/event.service';
import { getLdapLoginLabel } from '@/ldap/helpers.ee'; import { getLdapLoginLabel } from '@/ldap/helpers.ee';
import { License } from '@/license'; import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
@ -47,7 +46,6 @@ export class FrontendService {
private readonly mailer: UserManagementMailer, private readonly mailer: UserManagementMailer,
private readonly instanceSettings: InstanceSettings, private readonly instanceSettings: InstanceSettings,
private readonly urlService: UrlService, private readonly urlService: UrlService,
private readonly eventService: EventService,
) { ) {
loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes()); loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes());
void this.generateTypes(); void this.generateTypes();
@ -102,7 +100,7 @@ export class FrontendService {
urlBaseEditor: instanceBaseUrl, urlBaseEditor: instanceBaseUrl,
binaryDataMode: config.getEnv('binaryDataManager.mode'), binaryDataMode: config.getEnv('binaryDataManager.mode'),
nodeJsVersion: process.version.replace(/^v/, ''), nodeJsVersion: process.version.replace(/^v/, ''),
versionCli: '', versionCli: N8N_VERSION,
concurrency: config.getEnv('executions.concurrency.productionLimit'), concurrency: config.getEnv('executions.concurrency.productionLimit'),
authCookie: { authCookie: {
secure: config.getEnv('secure_cookie'), secure: config.getEnv('secure_cookie'),
@ -242,9 +240,7 @@ export class FrontendService {
this.writeStaticJSON('credentials', credentials); this.writeStaticJSON('credentials', credentials);
} }
getSettings(pushRef?: string): FrontendSettings { getSettings(): FrontendSettings {
this.eventService.emit('session-started', { pushRef });
const restEndpoint = this.globalConfig.endpoints.rest; const restEndpoint = this.globalConfig.endpoints.rest;
// Update all urls, in case `WEBHOOK_URL` was updated by `--tunnel` // Update all urls, in case `WEBHOOK_URL` was updated by `--tunnel`
@ -344,10 +340,6 @@ export class FrontendService {
return this.settings; return this.settings;
} }
addToSettings(newSettings: Record<string, unknown>) {
this.settings = { ...this.settings, ...newSettings };
}
private writeStaticJSON(name: string, data: INodeTypeBaseDescription[] | ICredentialType[]) { private writeStaticJSON(name: string, data: INodeTypeBaseDescription[] | ICredentialType[]) {
const { staticCacheDir } = this.instanceSettings; const { staticCacheDir } = this.instanceSettings;
const filePath = path.join(staticCacheDir, `types/${name}.json`); const filePath = path.join(staticCacheDir, `types/${name}.json`);

View file

@ -404,7 +404,7 @@ export class WorkflowsController {
return await this.workflowExecutionService.executeManually( return await this.workflowExecutionService.executeManually(
req.body, req.body,
req.user, req.user,
req.headers['push-ref'] as string, req.headers['push-ref'],
req.query.partialExecutionVersion === '-1' req.query.partialExecutionVersion === '-1'
? config.getEnv('featureFlags.partialExecutionVersionDefault') ? config.getEnv('featureFlags.partialExecutionVersionDefault')
: req.query.partialExecutionVersion, : req.query.partialExecutionVersion,

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-core", "name": "n8n-core",
"version": "1.60.0", "version": "1.61.0",
"description": "Core functionality of n8n", "description": "Core functionality of n8n",
"main": "dist/index", "main": "dist/index",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View file

@ -125,6 +125,32 @@ export class DirectedGraph {
return directChildren; return directChildren;
} }
private getChildrenRecursive(node: INode, children: Set<INode>) {
const directChildren = this.getDirectChildren(node);
for (const directChild of directChildren) {
// Break out if we found a cycle.
if (children.has(directChild.to)) {
continue;
}
children.add(directChild.to);
this.getChildrenRecursive(directChild.to, children);
}
return children;
}
/**
* Returns all nodes that are children of the node that is passed as an
* argument.
*
* If the node being passed in is a child of itself (e.g. is part of a
* cylce), the return set will contain it as well.
*/
getChildren(node: INode) {
return this.getChildrenRecursive(node, new Set());
}
getDirectParents(node: INode) { getDirectParents(node: INode) {
const nodeExists = this.nodes.get(node.name) === node; const nodeExists = this.nodes.get(node.name) === node;
a.ok(nodeExists); a.ok(nodeExists);

View file

@ -38,4 +38,52 @@ describe('DirectedGraph', () => {
graph, graph,
); );
}); });
describe('getChildren', () => {
// ┌─────┐ ┌─────┐ ┌─────┐
// │node1├───►│node2├──►│node3│
// └─────┘ └─────┘ └─────┘
test('returns all children', () => {
// ARRANGE
const node1 = createNodeData({ name: 'Node1' });
const node2 = createNodeData({ name: 'Node2' });
const node3 = createNodeData({ name: 'Node3' });
const graph = new DirectedGraph()
.addNodes(node1, node2, node3)
.addConnections({ from: node1, to: node2 }, { from: node2, to: node3 });
// ACT
const children = graph.getChildren(node1);
// ASSERT
expect(children.size).toBe(2);
expect(children).toEqual(new Set([node2, node3]));
});
// ┌─────┐ ┌─────┐ ┌─────┐
// ┌─►│node1├───►│node2├──►│node3├─┐
// │ └─────┘ └─────┘ └─────┘ │
// │ │
// └───────────────────────────────┘
test('terminates when finding a cycle', () => {
// ARRANGE
const node1 = createNodeData({ name: 'Node1' });
const node2 = createNodeData({ name: 'Node2' });
const node3 = createNodeData({ name: 'Node3' });
const graph = new DirectedGraph()
.addNodes(node1, node2, node3)
.addConnections(
{ from: node1, to: node2 },
{ from: node2, to: node3 },
{ from: node3, to: node1 },
);
// ACT
const children = graph.getChildren(node1);
// ASSERT
expect(children.size).toBe(3);
expect(children).toEqual(new Set([node1, node2, node3]));
});
});
}); });

View file

@ -0,0 +1,86 @@
import type { IRunData } from 'n8n-workflow';
import { cleanRunData } from '../cleanRunData';
import { DirectedGraph } from '../DirectedGraph';
import { createNodeData, toITaskData } from './helpers';
describe('cleanRunData', () => {
// ┌─────┐ ┌─────┐ ┌─────┐
// │node1├───►│node2├──►│node3│
// └─────┘ └─────┘ └─────┘
test('deletes all run data of all children and the node being passed in', () => {
// ARRANGE
const node1 = createNodeData({ name: 'Node1' });
const node2 = createNodeData({ name: 'Node2' });
const node3 = createNodeData({ name: 'Node3' });
const graph = new DirectedGraph()
.addNodes(node1, node2, node3)
.addConnections({ from: node1, to: node2 }, { from: node2, to: node3 });
const runData: IRunData = {
[node1.name]: [toITaskData([{ data: { value: 1 } }])],
[node2.name]: [toITaskData([{ data: { value: 2 } }])],
[node3.name]: [toITaskData([{ data: { value: 3 } }])],
};
// ACT
const newRunData = cleanRunData(runData, graph, [node1]);
// ASSERT
expect(newRunData).toEqual({});
});
// ┌─────┐ ┌─────┐ ┌─────┐
// │node1├───►│node2├──►│node3│
// └─────┘ └─────┘ └─────┘
test('retains the run data of parent nodes of the node being passed in', () => {
// ARRANGE
const node1 = createNodeData({ name: 'Node1' });
const node2 = createNodeData({ name: 'Node2' });
const node3 = createNodeData({ name: 'Node3' });
const graph = new DirectedGraph()
.addNodes(node1, node2, node3)
.addConnections({ from: node1, to: node2 }, { from: node2, to: node3 });
const runData: IRunData = {
[node1.name]: [toITaskData([{ data: { value: 1 } }])],
[node2.name]: [toITaskData([{ data: { value: 2 } }])],
[node3.name]: [toITaskData([{ data: { value: 3 } }])],
};
// ACT
const newRunData = cleanRunData(runData, graph, [node2]);
// ASSERT
expect(newRunData).toEqual({ [node1.name]: runData[node1.name] });
});
// ┌─────┐ ┌─────┐ ┌─────┐
// ┌─►│node1├───►│node2├──►│node3├─┐
// │ └─────┘ └─────┘ └─────┘ │
// │ │
// └───────────────────────────────┘
test('terminates when finding a cycle', () => {
// ARRANGE
const node1 = createNodeData({ name: 'Node1' });
const node2 = createNodeData({ name: 'Node2' });
const node3 = createNodeData({ name: 'Node3' });
const graph = new DirectedGraph()
.addNodes(node1, node2, node3)
.addConnections(
{ from: node1, to: node2 },
{ from: node2, to: node3 },
{ from: node3, to: node1 },
);
const runData: IRunData = {
[node1.name]: [toITaskData([{ data: { value: 1 } }])],
[node2.name]: [toITaskData([{ data: { value: 2 } }])],
[node3.name]: [toITaskData([{ data: { value: 3 } }])],
};
// ACT
const newRunData = cleanRunData(runData, graph, [node2]);
// ASSERT
// TODO: Find out if this is a desirable result in milestone 2
expect(newRunData).toEqual({});
});
});

View file

@ -0,0 +1,26 @@
import type { INode, IRunData } from 'n8n-workflow';
import type { DirectedGraph } from './DirectedGraph';
/**
* Returns new run data that does not contain data for any node that is a child
* of any start node.
* This does not mutate the `runData` being passed in.
*/
export function cleanRunData(
runData: IRunData,
graph: DirectedGraph,
startNodes: INode[],
): IRunData {
const newRunData: IRunData = { ...runData };
for (const startNode of startNodes) {
delete newRunData[startNode.name];
const children = graph.getChildren(startNode);
for (const child of children) {
delete newRunData[child.name];
}
}
return newRunData;
}

View file

@ -58,6 +58,7 @@ import {
findSubgraph, findSubgraph,
findTriggerForPartialExecution, findTriggerForPartialExecution,
} from './PartialExecutionUtils'; } from './PartialExecutionUtils';
import { cleanRunData } from './PartialExecutionUtils/cleanRunData';
export class WorkflowExecute { export class WorkflowExecute {
private status: ExecutionStatus = 'new'; private status: ExecutionStatus = 'new';
@ -347,7 +348,8 @@ export class WorkflowExecute {
} }
// 2. Find the Subgraph // 2. Find the Subgraph
const subgraph = findSubgraph(DirectedGraph.fromWorkflow(workflow), destinationNode, trigger); const graph = DirectedGraph.fromWorkflow(workflow);
const subgraph = findSubgraph(graph, destinationNode, trigger);
const filteredNodes = subgraph.getNodes(); const filteredNodes = subgraph.getNodes();
// 3. Find the Start Nodes // 3. Find the Start Nodes
@ -362,7 +364,7 @@ export class WorkflowExecute {
} }
// 6. Clean Run Data // 6. Clean Run Data
// TODO: const newRunData: IRunData = cleanRunData(runData, graph, startNodes);
// 7. Recreate Execution Stack // 7. Recreate Execution Stack
const { nodeExecutionStack, waitingExecution, waitingExecutionSource } = const { nodeExecutionStack, waitingExecution, waitingExecutionSource } =
@ -376,7 +378,7 @@ export class WorkflowExecute {
runNodeFilter: Array.from(filteredNodes.values()).map((node) => node.name), runNodeFilter: Array.from(filteredNodes.values()).map((node) => node.name),
}, },
resultData: { resultData: {
runData, runData: newRunData,
pinData, pinData,
}, },
executionData: { executionData: {

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-design-system", "name": "n8n-design-system",
"version": "1.50.0", "version": "1.51.0",
"main": "src/main.ts", "main": "src/main.ts",
"import": "src/main.ts", "import": "src/main.ts",
"scripts": { "scripts": {

View file

@ -31,7 +31,7 @@ const emit = defineEmits<{
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const rowsPerPageOptions = ref([10, 25, 50, 100]); const rowsPerPageOptions = ref([1, 10, 25, 50, 100]);
const $style = useCssModule(); const $style = useCssModule();

View file

@ -193,6 +193,7 @@ defineExpose({ inputRef });
:label="label" :label="label"
:tooltip-text="tooltipText" :tooltip-text="tooltipText"
:required="required && showRequiredAsterisk" :required="required && showRequiredAsterisk"
:size="labelSize"
> >
<template #content> <template #content>
{{ tooltipText }} {{ tooltipText }}
@ -210,6 +211,7 @@ defineExpose({ inputRef });
:label="label" :label="label"
:tooltip-text="tooltipText" :tooltip-text="tooltipText"
:required="required && showRequiredAsterisk" :required="required && showRequiredAsterisk"
:size="labelSize"
> >
<div :class="showErrors ? $style.errorInput : ''" @keydown.stop @keydown.enter="onEnter"> <div :class="showErrors ? $style.errorInput : ''" @keydown.stop @keydown.enter="onEnter">
<slot v-if="hasDefaultSlot" /> <slot v-if="hasDefaultSlot" />
@ -223,6 +225,7 @@ defineExpose({ inputRef });
:disabled="disabled" :disabled="disabled"
:name="name" :name="name"
:teleported="teleported" :teleported="teleported"
:size="tagSize"
@update:model-value="onUpdateModelValue" @update:model-value="onUpdateModelValue"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
@ -246,6 +249,7 @@ defineExpose({ inputRef });
:maxlength="maxlength" :maxlength="maxlength"
:autocomplete="autocomplete" :autocomplete="autocomplete"
:disabled="disabled" :disabled="disabled"
:size="tagSize"
@update:model-value="onUpdateModelValue" @update:model-value="onUpdateModelValue"
@blur="onBlur" @blur="onBlur"
@focus="onFocus" @focus="onFocus"

View file

@ -5,7 +5,7 @@ import N8nIcon from '../N8nIcon';
import N8nText from '../N8nText'; import N8nText from '../N8nText';
import N8nTooltip from '../N8nTooltip'; import N8nTooltip from '../N8nTooltip';
const SIZE = ['small', 'medium'] as const; const SIZE = ['small', 'medium', 'large'] as const;
interface InputLabelProps { interface InputLabelProps {
compact?: boolean; compact?: boolean;

View file

@ -9,6 +9,7 @@
window.BASE_PATH = '/{{BASE_PATH}}/'; window.BASE_PATH = '/{{BASE_PATH}}/';
window.REST_ENDPOINT = '{{REST_ENDPOINT}}'; window.REST_ENDPOINT = '{{REST_ENDPOINT}}';
</script> </script>
<script src="/{{REST_ENDPOINT}}/sentry.js"></script>
<script>!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled getFeatureFlag onFeatureFlags reloadFeatureFlags".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[])</script> <script>!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled getFeatureFlag onFeatureFlags reloadFeatureFlags".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[])</script>
<title>n8n.io - Workflow Automation</title> <title>n8n.io - Workflow Automation</title>

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-editor-ui", "name": "n8n-editor-ui",
"version": "1.60.0", "version": "1.61.0",
"description": "Workflow Editor UI for n8n", "description": "Workflow Editor UI for n8n",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@ -38,6 +38,7 @@
"@n8n/codemirror-lang": "workspace:*", "@n8n/codemirror-lang": "workspace:*",
"@n8n/codemirror-lang-sql": "^1.0.2", "@n8n/codemirror-lang-sql": "^1.0.2",
"@n8n/permissions": "workspace:*", "@n8n/permissions": "workspace:*",
"@sentry/vue": "^8.31.0",
"@vue-flow/background": "^1.3.0", "@vue-flow/background": "^1.3.0",
"@vue-flow/controls": "^1.1.1", "@vue-flow/controls": "^1.1.1",
"@vue-flow/core": "^1.33.5", "@vue-flow/core": "^1.33.5",
@ -83,7 +84,7 @@
"@faker-js/faker": "^8.0.2", "@faker-js/faker": "^8.0.2",
"@iconify/json": "^2.2.228", "@iconify/json": "^2.2.228",
"@pinia/testing": "^0.1.3", "@pinia/testing": "^0.1.3",
"@sentry/vite-plugin": "^2.5.0", "@sentry/vite-plugin": "^2.22.4",
"@types/dateformat": "^3.0.0", "@types/dateformat": "^3.0.0",
"@types/file-saver": "^2.0.1", "@types/file-saver": "^2.0.1",
"@types/humanize-duration": "^3.27.1", "@types/humanize-duration": "^3.27.1",

View file

@ -0,0 +1,6 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
export async function sessionStarted(context: IRestApiContext): Promise<void> {
return await makeRestApiRequest(context, 'GET', '/events/session-started');
}

View file

@ -10,7 +10,7 @@ import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
import { format } from 'prettier'; import { format } from 'prettier';
import jsParser from 'prettier/plugins/babel'; import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree'; import * as estree from 'prettier/plugins/estree';
import { type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { CODE_NODE_TYPE } from '@/constants'; import { CODE_NODE_TYPE } from '@/constants';
import { codeNodeEditorEventBus } from '@/event-bus'; import { codeNodeEditorEventBus } from '@/event-bus';
@ -26,6 +26,7 @@ import { useLinter } from './linter';
import { codeNodeEditorTheme } from './theme'; import { codeNodeEditorTheme } from './theme';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { dropInCodeEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
type Props = { type Props = {
mode: CodeExecutionMode; mode: CodeExecutionMode;
@ -51,6 +52,7 @@ const emit = defineEmits<{
const message = useMessage(); const message = useMessage();
const editor = ref(null) as Ref<EditorView | null>; const editor = ref(null) as Ref<EditorView | null>;
const languageCompartment = ref(new Compartment()); const languageCompartment = ref(new Compartment());
const dragAndDropCompartment = ref(new Compartment());
const linterCompartment = ref(new Compartment()); const linterCompartment = ref(new Compartment());
const isEditorHovered = ref(false); const isEditorHovered = ref(false);
const isEditorFocused = ref(false); const isEditorFocused = ref(false);
@ -95,6 +97,7 @@ onMounted(() => {
extensions.push( extensions.push(
...writableEditorExtensions, ...writableEditorExtensions,
dragAndDropCompartment.value.of(dragAndDropExtension.value),
EditorView.domEventHandlers({ EditorView.domEventHandlers({
focus: () => { focus: () => {
isEditorFocused.value = true; isEditorFocused.value = true;
@ -151,6 +154,12 @@ const placeholder = computed(() => {
return CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? ''; return CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '';
}); });
const dragAndDropEnabled = computed(() => {
return !props.isReadOnly && props.mode === 'runOnceForEachItem';
});
const dragAndDropExtension = computed(() => (dragAndDropEnabled.value ? mappingDropCursor() : []));
// eslint-disable-next-line vue/return-in-computed-property // eslint-disable-next-line vue/return-in-computed-property
const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => { const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => {
switch (props.language) { switch (props.language) {
@ -188,6 +197,12 @@ watch(
}, },
); );
watch(dragAndDropExtension, (extension) => {
editor.value?.dispatch({
effects: dragAndDropCompartment.value.reconfigure(extension),
});
});
watch( watch(
() => props.language, () => props.language,
(_newLanguage, previousLanguage: CodeNodeEditorLanguage) => { (_newLanguage, previousLanguage: CodeNodeEditorLanguage) => {
@ -202,7 +217,6 @@ watch(
reloadLinter(); reloadLinter();
}, },
); );
watch( watch(
aiEnabled, aiEnabled,
async (isEnabled) => { async (isEnabled) => {
@ -361,6 +375,12 @@ function onAiLoadStart() {
function onAiLoadEnd() { function onAiLoadEnd() {
isLoadingAIResponse.value = false; isLoadingAIResponse.value = false;
} }
async function onDrop(value: string, event: MouseEvent) {
if (!editor.value) return;
await dropInCodeEditor(toRaw(editor.value), event, value);
}
</script> </script>
<template> <template>
@ -384,10 +404,20 @@ function onAiLoadEnd() {
data-test-id="code-node-tab-code" data-test-id="code-node-tab-code"
:class="$style.fillHeight" :class="$style.fillHeight"
> >
<div <DraggableTarget type="mapping" :disabled="!dragAndDropEnabled" @drop="onDrop">
ref="codeNodeEditorRef" <template #default="{ activeDrop, droppable }">
:class="['ph-no-capture', 'code-editor-tabs', $style.editorInput, $style.fillHeight]" <div
/> ref="codeNodeEditorRef"
:class="[
'ph-no-capture',
'code-editor-tabs',
$style.editorInput,
$style.fillHeight,
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
]"
/>
</template>
</DraggableTarget>
<slot name="suffix" /> <slot name="suffix" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane <el-tab-pane
@ -407,7 +437,19 @@ function onAiLoadEnd() {
</el-tabs> </el-tabs>
<!-- If AskAi not enabled, there's no point in rendering tabs --> <!-- If AskAi not enabled, there's no point in rendering tabs -->
<div v-else :class="$style.fillHeight"> <div v-else :class="$style.fillHeight">
<div ref="codeNodeEditorRef" :class="['ph-no-capture', $style.fillHeight]" /> <DraggableTarget type="mapping" :disabled="!dragAndDropEnabled" @drop="onDrop">
<template #default="{ activeDrop, droppable }">
<div
ref="codeNodeEditorRef"
:class="[
'ph-no-capture',
$style.fillHeight,
$style.editorInput,
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
]"
/>
</template>
</DraggableTarget>
<slot name="suffix" /> <slot name="suffix" />
</div> </div>
</div> </div>
@ -415,7 +457,7 @@ function onAiLoadEnd() {
<style scoped lang="scss"> <style scoped lang="scss">
:deep(.el-tabs) { :deep(.el-tabs) {
.code-editor-tabs .cm-editor { .cm-editor {
border: 0; border: 0;
} }
} }
@ -454,4 +496,21 @@ function onAiLoadEnd() {
.fillHeight { .fillHeight {
height: 100%; height: 100%;
} }
.editorInput.droppable {
:global(.cm-editor) {
border-color: var(--color-ndv-droppable-parameter);
border-style: dashed;
border-width: 1.5px;
}
}
.editorInput.activeDrop {
:global(.cm-editor) {
border-color: var(--color-success);
border-style: solid;
cursor: grabbing;
border-width: 1px;
}
}
</style> </style>

View file

@ -280,8 +280,7 @@ const requiredPropertiesFilled = computed(() => {
const credentialPermissions = computed(() => { const credentialPermissions = computed(() => {
return getResourcePermissions( return getResourcePermissions(
((credentialId.value ? currentCredential.value : credentialData.value) as ICredentialsResponse) (currentCredential.value as ICredentialsResponse)?.scopes ?? homeProject.value?.scopes,
?.scopes,
).credential; ).credential;
}); });
@ -341,11 +340,8 @@ onMounted(async () => {
credentialTypeName: defaultCredentialTypeName.value, credentialTypeName: defaultCredentialTypeName.value,
}); });
const scopes = homeProject.value?.scopes ?? [];
credentialData.value = { credentialData.value = {
...credentialData.value, ...credentialData.value,
scopes,
...(homeProject.value ? { homeProject: homeProject.value } : {}), ...(homeProject.value ? { homeProject: homeProject.value } : {}),
}; };
} else { } else {

View file

@ -19,7 +19,7 @@ import OutputItemSelect from './InlineExpressionEditor/OutputItemSelect.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useDebounce } from '@/composables/useDebounce'; import { useDebounce } from '@/composables/useDebounce';
import DraggableTarget from './DraggableTarget.vue'; import DraggableTarget from './DraggableTarget.vue';
import { dropInEditor } from '@/plugins/codemirror/dragAndDrop'; import { dropInExpressionEditor } from '@/plugins/codemirror/dragAndDrop';
import { APP_MODALS_ELEMENT_ID } from '@/constants'; import { APP_MODALS_ELEMENT_ID } from '@/constants';
@ -119,7 +119,7 @@ function closeDialog() {
async function onDrop(expression: string, event: MouseEvent) { async function onDrop(expression: string, event: MouseEvent) {
if (!inputEditor.value) return; if (!inputEditor.value) return;
await dropInEditor(toRaw(inputEditor.value), event, expression); await dropInExpressionEditor(toRaw(inputEditor.value), event, expression);
} }
</script> </script>

View file

@ -10,7 +10,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils'; import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { dropInEditor } from '@/plugins/codemirror/dragAndDrop'; import { dropInExpressionEditor } from '@/plugins/codemirror/dragAndDrop';
import type { Segment } from '@/types/expressions'; import type { Segment } from '@/types/expressions';
import { startCompletion } from '@codemirror/autocomplete'; import { startCompletion } from '@codemirror/autocomplete';
import type { EditorState, SelectionRange } from '@codemirror/state'; import type { EditorState, SelectionRange } from '@codemirror/state';
@ -119,7 +119,9 @@ async function onDrop(value: string, event: MouseEvent) {
if (!editor) return; if (!editor) return;
const droppedSelection = await dropInEditor(toRaw(editor), event, value); const droppedSelection = await dropInExpressionEditor(toRaw(editor), event, value);
if (!ndvStore.isMappingOnboarded) ndvStore.setMappingOnboarded();
if (!ndvStore.isAutocompleteOnboarded) { if (!ndvStore.isAutocompleteOnboarded) {
setCursorPosition((droppedSelection.ranges.at(0)?.head ?? 3) - 3); setCursorPosition((droppedSelection.ranges.at(0)?.head ?? 3) - 3);

View file

@ -90,8 +90,8 @@ const allIssues = computed(() => {
const now = computed(() => DateTime.now().toISO()); const now = computed(() => DateTime.now().toISO());
const leftParameter = computed<INodeProperties>(() => ({ const leftParameter = computed<INodeProperties>(() => ({
name: '', name: 'left',
displayName: '', displayName: 'Left',
default: '', default: '',
placeholder: placeholder:
operator.value.type === 'dateTime' operator.value.type === 'dateTime'
@ -103,8 +103,8 @@ const leftParameter = computed<INodeProperties>(() => ({
const rightParameter = computed<INodeProperties>(() => { const rightParameter = computed<INodeProperties>(() => {
const type = operator.value.rightType ?? operator.value.type; const type = operator.value.rightType ?? operator.value.type;
return { return {
name: '', name: 'right',
displayName: '', displayName: 'Right',
default: '', default: '',
placeholder: placeholder:
type === 'dateTime' ? now.value : i18n.baseText('filter.condition.placeholderRight'), type === 'dateTime' ? now.value : i18n.baseText('filter.condition.placeholderRight'),

View file

@ -20,7 +20,7 @@ import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree'; import * as estree from 'prettier/plugins/estree';
import htmlParser from 'prettier/plugins/html'; import htmlParser from 'prettier/plugins/html';
import cssParser from 'prettier/plugins/postcss'; import cssParser from 'prettier/plugins/postcss';
import { computed, onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, toRaw, toValue, watch } from 'vue';
import { htmlEditorEventBus } from '@/event-bus'; import { htmlEditorEventBus } from '@/event-bus';
import { useExpressionEditor } from '@/composables/useExpressionEditor'; import { useExpressionEditor } from '@/composables/useExpressionEditor';
@ -37,6 +37,7 @@ import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme'; import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import type { Range, Section } from './types'; import type { Range, Section } from './types';
import { nonTakenRanges } from './utils'; import { nonTakenRanges } from './utils';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
type Props = { type Props = {
modelValue: string; modelValue: string;
@ -84,6 +85,7 @@ const extensions = computed(() => [
dropCursor(), dropCursor(),
indentOnInput(), indentOnInput(),
highlightActiveLine(), highlightActiveLine(),
mappingDropCursor(),
]); ]);
const { const {
editor: editorRef, editor: editorRef,
@ -238,11 +240,25 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
htmlEditorEventBus.off('format-html', formatHtml); htmlEditorEventBus.off('format-html', formatHtml);
}); });
async function onDrop(value: string, event: MouseEvent) {
if (!editorRef.value) return;
await dropInExpressionEditor(toRaw(editorRef.value), event, value);
}
</script> </script>
<template> <template>
<div :class="$style.editor"> <div :class="$style.editor">
<div ref="htmlEditor" data-test-id="html-editor-container"></div> <DraggableTarget type="mapping" :disabled="isReadOnly" @drop="onDrop">
<template #default="{ activeDrop, droppable }">
<div
ref="htmlEditor"
:class="{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable }"
data-test-id="html-editor-container"
></div
></template>
</DraggableTarget>
<slot name="suffix" /> <slot name="suffix" />
</div> </div>
</template> </template>
@ -255,4 +271,21 @@ onBeforeUnmount(() => {
height: 100%; height: 100%;
} }
} }
.droppable {
:global(.cm-editor) {
border-color: var(--color-ndv-droppable-parameter);
border-style: dashed;
border-width: 1.5px;
}
}
.activeDrop {
:global(.cm-editor) {
border-color: var(--color-success);
border-style: solid;
cursor: grabbing;
border-width: 1px;
}
}
</style> </style>

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { computed, ref, watch } from 'vue'; import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { EditorSelection, EditorState, type SelectionRange } from '@codemirror/state'; import { EditorSelection, EditorState, type SelectionRange } from '@codemirror/state';
import { type Completion, CompletionContext } from '@codemirror/autocomplete'; import { type Completion, CompletionContext } from '@codemirror/autocomplete';
import { datatypeCompletions } from '@/plugins/codemirror/completions/datatype.completions'; import { datatypeCompletions } from '@/plugins/codemirror/completions/datatype.completions';
@ -75,10 +75,18 @@ function getCompletionsWithDot(): readonly Completion[] {
return completionResult?.options ?? []; return completionResult?.options ?? [];
} }
watch(tip, (newTip) => { onBeforeUnmount(() => {
ndvStore.setHighlightDraggables(!ndvStore.isMappingOnboarded && newTip === 'drag'); ndvStore.setHighlightDraggables(false);
}); });
watch(
tip,
(newTip) => {
ndvStore.setHighlightDraggables(!ndvStore.isMappingOnboarded && newTip === 'drag');
},
{ immediate: true },
);
watchDebounced( watchDebounced(
[() => props.selection, () => props.unresolvedExpression], [() => props.selection, () => props.unresolvedExpression],
() => { () => {

View file

@ -27,6 +27,7 @@ describe('InlineExpressionTip.vue', () => {
mockNdvState = { mockNdvState = {
hasInputData: true, hasInputData: true,
isNDVDataEmpty: vi.fn(() => true), isNDVDataEmpty: vi.fn(() => true),
setHighlightDraggables: vi.fn(),
}; };
}); });
@ -43,11 +44,16 @@ describe('InlineExpressionTip.vue', () => {
hasInputData: true, hasInputData: true,
isNDVDataEmpty: vi.fn(() => false), isNDVDataEmpty: vi.fn(() => false),
focusedMappableInput: 'Some Input', focusedMappableInput: 'Some Input',
setHighlightDraggables: vi.fn(),
}; };
const { container } = renderComponent(InlineExpressionTip, { const { container, unmount } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(), pinia: createTestingPinia(),
}); });
expect(mockNdvState.setHighlightDraggables).toHaveBeenCalledWith(true);
expect(container).toHaveTextContent('Tip: Drag aninput fieldfrom the left to use it here.'); expect(container).toHaveTextContent('Tip: Drag aninput fieldfrom the left to use it here.');
unmount();
expect(mockNdvState.setHighlightDraggables).toHaveBeenCalledWith(false);
}); });
}); });
@ -58,6 +64,7 @@ describe('InlineExpressionTip.vue', () => {
isInputParentOfActiveNode: true, isInputParentOfActiveNode: true,
isNDVDataEmpty: vi.fn(() => false), isNDVDataEmpty: vi.fn(() => false),
focusedMappableInput: 'Some Input', focusedMappableInput: 'Some Input',
setHighlightDraggables: vi.fn(),
}; };
const { container } = renderComponent(InlineExpressionTip, { const { container } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(), pinia: createTestingPinia(),

View file

@ -24,6 +24,7 @@ import {
} from '@/plugins/codemirror/keymap'; } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang'; import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
type Props = { type Props = {
modelValue: string; modelValue: string;
@ -69,6 +70,7 @@ const extensions = computed(() => {
foldGutter(), foldGutter(),
dropCursor(), dropCursor(),
bracketMatching(), bracketMatching(),
mappingDropCursor(),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => { EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (!viewUpdate.docChanged || !editor.value) return; if (!viewUpdate.docChanged || !editor.value) return;
emit('update:modelValue', editor.value?.state.doc.toString()); emit('update:modelValue', editor.value?.state.doc.toString());

View file

@ -510,6 +510,28 @@ const isCodeNode = computed(
const isHtmlNode = computed(() => !!node.value && node.value.type === HTML_NODE_TYPE); const isHtmlNode = computed(() => !!node.value && node.value.type === HTML_NODE_TYPE);
const isInputTypeString = computed(() => props.parameter.type === 'string');
const isInputTypeNumber = computed(() => props.parameter.type === 'number');
const isInputDataEmpty = computed(() => ndvStore.isNDVDataEmpty('input'));
const isDropDisabled = computed(
() =>
props.parameter.noDataExpression ||
props.isReadOnly ||
isResourceLocatorParameter.value ||
isModelValueExpression.value,
);
const showDragnDropTip = computed(
() =>
isFocused.value &&
(isInputTypeString.value || isInputTypeNumber.value) &&
!isModelValueExpression.value &&
!isDropDisabled.value &&
(!ndvStore.hasInputData || !isInputDataEmpty.value) &&
!ndvStore.isMappingOnboarded &&
ndvStore.isInputParentOfActiveNode,
);
function isRemoteParameterOption(option: INodePropertyOptions) { function isRemoteParameterOption(option: INodePropertyOptions) {
return remoteParameterOptionsKeys.value.includes(option.name); return remoteParameterOptionsKeys.value.includes(option.name);
} }
@ -965,7 +987,11 @@ onUpdated(async () => {
</script> </script>
<template> <template>
<div ref="wrapper" :class="parameterInputClasses" @keydown.stop> <div
ref="wrapper"
:class="[parameterInputClasses, { [$style.tipVisible]: showDragnDropTip }]"
@keydown.stop
>
<ExpressionEditModal <ExpressionEditModal
:dialog-visible="expressionEditDialogVisible" :dialog-visible="expressionEditDialogVisible"
:model-value="modelValueExpressionEdit" :model-value="modelValueExpressionEdit"
@ -1447,6 +1473,9 @@ onUpdated(async () => {
:disabled="isReadOnly" :disabled="isReadOnly"
@update:model-value="valueChanged" @update:model-value="valueChanged"
/> />
<div v-if="showDragnDropTip" :class="$style.tip">
<InlineExpressionTip />
</div>
</div> </div>
<ParameterIssues <ParameterIssues
@ -1477,6 +1506,7 @@ onUpdated(async () => {
.parameter-input { .parameter-input {
display: inline-block; display: inline-block;
position: relative;
:deep(.color-input) { :deep(.color-input) {
display: flex; display: flex;
@ -1609,3 +1639,23 @@ onUpdated(async () => {
} }
} }
</style> </style>
<style lang="scss" module>
.tipVisible {
--input-border-bottom-left-radius: 0;
--input-border-bottom-right-radius: 0;
}
.tip {
position: absolute;
z-index: 2;
top: 100%;
background: var(--color-code-background);
border: var(--border-base);
border-top: none;
width: 100%;
box-shadow: 0 2px 6px 0 rgba(#441c17, 0.1);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
</style>

View file

@ -13,7 +13,6 @@ import { hasExpressionMapping, hasOnlyListMode, isValueExpression } from '@/util
import { isResourceLocatorValue } from '@/utils/typeGuards'; import { isResourceLocatorValue } from '@/utils/typeGuards';
import { createEventBus } from 'n8n-design-system/utils'; import { createEventBus } from 'n8n-design-system/utils';
import type { INodeProperties, IParameterLabel, NodeParameterValueType } from 'n8n-workflow'; import type { INodeProperties, IParameterLabel, NodeParameterValueType } from 'n8n-workflow';
import InlineExpressionTip from './InlineExpressionEditor/InlineExpressionTip.vue';
type Props = { type Props = {
parameter: INodeProperties; parameter: INodeProperties;
@ -57,8 +56,7 @@ const ndvStore = useNDVStore();
const node = computed(() => ndvStore.activeNode); const node = computed(() => ndvStore.activeNode);
const hint = computed(() => i18n.nodeText().hint(props.parameter, props.path)); const hint = computed(() => i18n.nodeText().hint(props.parameter, props.path));
const isInputTypeString = computed(() => props.parameter.type === 'string');
const isInputTypeNumber = computed(() => props.parameter.type === 'number');
const isResourceLocator = computed( const isResourceLocator = computed(
() => props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector', () => props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector',
); );
@ -73,17 +71,6 @@ const isExpression = computed(() => isValueExpression(props.parameter, props.val
const showExpressionSelector = computed(() => const showExpressionSelector = computed(() =>
isResourceLocator.value ? !hasOnlyListMode(props.parameter) : true, isResourceLocator.value ? !hasOnlyListMode(props.parameter) : true,
); );
const isInputDataEmpty = computed(() => ndvStore.isNDVDataEmpty('input'));
const showDragnDropTip = computed(
() =>
focused.value &&
(isInputTypeString.value || isInputTypeNumber.value) &&
!isExpression.value &&
!isDropDisabled.value &&
(!ndvStore.hasInputData || !isInputDataEmpty.value) &&
!ndvStore.isMappingOnboarded &&
ndvStore.isInputParentOfActiveNode,
);
function onFocus() { function onFocus() {
focused.value = true; focused.value = true;
@ -205,7 +192,7 @@ function onDrop(newParamValue: string) {
<template> <template>
<n8n-input-label <n8n-input-label
:class="[$style.wrapper, { [$style.tipVisible]: showDragnDropTip }]" :class="[$style.wrapper]"
:label="hideLabel ? '' : i18n.nodeText().inputLabelDisplayName(parameter, path)" :label="hideLabel ? '' : i18n.nodeText().inputLabelDisplayName(parameter, path)"
:tooltip-text="hideLabel ? '' : i18n.nodeText().inputLabelDescription(parameter, path)" :tooltip-text="hideLabel ? '' : i18n.nodeText().inputLabelDescription(parameter, path)"
:show-tooltip="focused" :show-tooltip="focused"
@ -258,9 +245,6 @@ function onDrop(newParamValue: string) {
/> />
</template> </template>
</DraggableTarget> </DraggableTarget>
<div v-if="showDragnDropTip" :class="$style.tip">
<InlineExpressionTip />
</div>
<div <div
:class="{ :class="{
[$style.options]: true, [$style.options]: true,
@ -292,24 +276,6 @@ function onDrop(newParamValue: string) {
} }
} }
.tipVisible {
--input-border-bottom-left-radius: 0;
--input-border-bottom-right-radius: 0;
}
.tip {
position: absolute;
z-index: 2;
top: 100%;
background: var(--color-code-background);
border: var(--border-base);
border-top: none;
width: 100%;
box-shadow: 0 2px 6px 0 rgba(#441c17, 0.1);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.options { .options {
position: absolute; position: absolute;
bottom: -22px; bottom: -22px;

View file

@ -200,7 +200,7 @@ export default defineComponent({
MAX_DISPLAY_ITEMS_AUTO_ALL, MAX_DISPLAY_ITEMS_AUTO_ALL,
currentPage: 1, currentPage: 1,
pageSize: 10, pageSize: 10,
pageSizes: [10, 25, 50, 100], pageSizes: [1, 10, 25, 50, 100],
pinDataDiscoveryTooltipVisible: false, pinDataDiscoveryTooltipVisible: false,
isControlledPinDataTooltip: false, isControlledPinDataTooltip: false,

View file

@ -42,6 +42,7 @@ type SchemaNode = {
depth: number; depth: number;
loading: boolean; loading: boolean;
open: boolean; open: boolean;
connectedOutputIndexes: number[];
itemsCount: number | null; itemsCount: number | null;
schema: Schema | null; schema: Schema | null;
}; };
@ -94,6 +95,7 @@ const nodes = computed(() => {
return { return {
node: fullNode, node: fullNode,
connectedOutputIndexes: node.indicies,
depth: node.depth, depth: node.depth,
itemsCount, itemsCount,
nodeType, nodeType,
@ -141,19 +143,17 @@ const highlight = computed(() => ndvStore.highlightDraggables);
const allNodesOpen = computed(() => nodes.value.every((node) => node.open)); const allNodesOpen = computed(() => nodes.value.every((node) => node.open));
const noNodesOpen = computed(() => nodes.value.every((node) => !node.open)); const noNodesOpen = computed(() => nodes.value.every((node) => !node.open));
const loadNodeData = async (node: INodeUi) => { const loadNodeData = async ({ node, connectedOutputIndexes }: SchemaNode) => {
const pinData = workflowsStore.pinDataByNodeName(node.name); const pinData = workflowsStore.pinDataByNodeName(node.name);
const data = const data =
pinData ?? pinData ??
executionDataToJson( connectedOutputIndexes
getNodeInputData( .map((outputIndex) =>
node, executionDataToJson(
props.runIndex, getNodeInputData(node, props.runIndex, outputIndex, props.paneType, props.connectionType),
props.outputIndex, ),
props.paneType, )
props.connectionType, .flat();
) ?? [],
);
nodesData.value[node.name] = { nodesData.value[node.name] = {
schema: getSchemaForExecutionData(data), schema: getSchemaForExecutionData(data),
@ -161,7 +161,8 @@ const loadNodeData = async (node: INodeUi) => {
}; };
}; };
const toggleOpenNode = async ({ node, schema, open }: SchemaNode, exclusive = false) => { const toggleOpenNode = async (schemaNode: SchemaNode, exclusive = false) => {
const { node, schema, open } = schemaNode;
disableScrollInView.value = false; disableScrollInView.value = false;
if (open) { if (open) {
nodesOpen.value[node.name] = false; nodesOpen.value[node.name] = false;
@ -170,7 +171,7 @@ const toggleOpenNode = async ({ node, schema, open }: SchemaNode, exclusive = fa
if (!schema) { if (!schema) {
nodesLoading.value[node.name] = true; nodesLoading.value[node.name] = true;
await loadNodeData(node); await loadNodeData(schemaNode);
nodesLoading.value[node.name] = false; nodesLoading.value[node.name] = false;
} }
@ -182,8 +183,8 @@ const toggleOpenNode = async ({ node, schema, open }: SchemaNode, exclusive = fa
}; };
const openAllNodes = async () => { const openAllNodes = async () => {
const nodesToLoad = nodes.value.filter((node) => !node.schema).map(({ node }) => node); const nodesToLoad = nodes.value.filter((node) => !node.schema);
await Promise.all(nodesToLoad.map(async (node) => await loadNodeData(node))); await Promise.all(nodesToLoad.map(loadNodeData));
nodesOpen.value = Object.fromEntries(nodes.value.map(({ node }) => [node.name, true])); nodesOpen.value = Object.fromEntries(nodes.value.map(({ node }) => [node.name, true]));
}; };

View file

@ -0,0 +1,150 @@
import { within } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { useRoute } from 'vue-router';
import { createComponentRenderer } from '@/__tests__/render';
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
import { createTestingPinia } from '@pinia/testing';
import { createEventBus } from 'n8n-design-system';
import type { SourceControlAggregatedFile } from '@/Interface';
const eventBus = createEventBus();
vi.mock('vue-router', () => ({
useRoute: vi.fn().mockReturnValue({
params: vi.fn(),
fullPath: vi.fn(),
}),
RouterLink: vi.fn(),
}));
let route: ReturnType<typeof useRoute>;
const renderModal = createComponentRenderer(SourceControlPushModal, {
global: {
stubs: {
Modal: {
template: `
<div>
<slot name="header" />
<slot name="title" />
<slot name="content" />
<slot name="footer" />
</div>
`,
},
},
},
});
describe('SourceControlPushModal', () => {
beforeEach(() => {
route = useRoute();
});
it('mounts', () => {
vi.spyOn(route, 'fullPath', 'get').mockReturnValue('');
const { getByTitle } = renderModal({
pinia: createTestingPinia(),
props: {
data: {
eventBus,
status: [],
},
},
});
expect(getByTitle('Commit and push changes')).toBeInTheDocument();
});
it('should toggle checkboxes', async () => {
const status: SourceControlAggregatedFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'My workflow 1',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/gTbbBkkYTnNyX1jD.json',
updatedAt: '2024-09-20T10:31:40.000Z',
},
{
id: 'JIGKevgZagmJAnM6',
name: 'My workflow 2',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/JIGKevgZagmJAnM6.json',
updatedAt: '2024-09-20T14:42:51.968Z',
},
];
vi.spyOn(route, 'fullPath', 'get').mockReturnValue('/home/workflows');
const { getByTestId, getAllByTestId } = renderModal({
pinia: createTestingPinia(),
props: {
data: {
eventBus,
status,
},
},
});
const files = getAllByTestId('source-control-push-modal-file-checkbox');
expect(files).toHaveLength(2);
await userEvent.click(files[0]);
expect(within(files[0]).getByRole('checkbox')).toBeChecked();
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
await userEvent.click(within(files[0]).getByRole('checkbox'));
expect(within(files[0]).getByRole('checkbox')).not.toBeChecked();
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
await userEvent.click(within(files[1]).getByRole('checkbox'));
expect(within(files[0]).getByRole('checkbox')).not.toBeChecked();
expect(within(files[1]).getByRole('checkbox')).toBeChecked();
await userEvent.click(files[1]);
expect(within(files[0]).getByRole('checkbox')).not.toBeChecked();
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
await userEvent.click(within(files[0]).getByText('My workflow 2'));
expect(within(files[0]).getByRole('checkbox')).toBeChecked();
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
await userEvent.click(within(files[1]).getByText('My workflow 1'));
expect(within(files[0]).getByRole('checkbox')).toBeChecked();
expect(within(files[1]).getByRole('checkbox')).toBeChecked();
await userEvent.click(within(files[1]).getByText('My workflow 1'));
expect(within(files[0]).getByRole('checkbox')).toBeChecked();
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
await userEvent.click(getByTestId('source-control-push-modal-toggle-all'));
expect(within(files[0]).getByRole('checkbox')).toBeChecked();
expect(within(files[1]).getByRole('checkbox')).toBeChecked();
await userEvent.click(within(files[0]).getByText('My workflow 2'));
await userEvent.click(within(files[1]).getByText('My workflow 1'));
expect(within(files[0]).getByRole('checkbox')).not.toBeChecked();
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
expect(
within(getByTestId('source-control-push-modal-toggle-all')).getByRole('checkbox'),
).not.toBeChecked();
await userEvent.click(within(files[0]).getByText('My workflow 2'));
await userEvent.click(within(files[1]).getByText('My workflow 1'));
expect(within(files[0]).getByRole('checkbox')).toBeChecked();
expect(within(files[1]).getByRole('checkbox')).toBeChecked();
expect(
within(getByTestId('source-control-push-modal-toggle-all')).getByRole('checkbox'),
).toBeChecked();
await userEvent.click(getByTestId('source-control-push-modal-toggle-all'));
expect(within(files[0]).getByRole('checkbox')).not.toBeChecked();
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
});
});

View file

@ -262,66 +262,66 @@ function getStatusText(file: SourceControlAggregatedFile): string {
<div :class="$style.container"> <div :class="$style.container">
<div v-if="files.length > 0"> <div v-if="files.length > 0">
<div v-if="workflowFiles.length > 0"> <div v-if="workflowFiles.length > 0">
<n8n-text> <n8n-text tag="div" class="mb-l">
{{ i18n.baseText('settings.sourceControl.modals.push.description') }} {{ i18n.baseText('settings.sourceControl.modals.push.description') }}
<n8n-link :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')"> <n8n-link :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }} {{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
</n8n-link> </n8n-link>
</n8n-text> </n8n-text>
<div class="mt-l mb-2xs"> <n8n-checkbox
<n8n-checkbox :class="$style.selectAll"
:indeterminate="selectAllIndeterminate" :indeterminate="selectAllIndeterminate"
:model-value="selectAll" :model-value="selectAll"
@update:model-value="onToggleSelectAll" data-test-id="source-control-push-modal-toggle-all"
> @update:model-value="onToggleSelectAll"
<n8n-text bold tag="strong">
{{ i18n.baseText('settings.sourceControl.modals.push.workflowsToCommit') }}
</n8n-text>
<n8n-text v-show="workflowFiles.length > 0" tag="strong">
({{ stagedWorkflowFiles.length }}/{{ workflowFiles.length }})
</n8n-text>
</n8n-checkbox>
</div>
<n8n-card
v-for="file in sortedFiles"
v-show="!defaultStagedFileTypes.includes(file.type)"
:key="file.file"
:class="$style.listItem"
@click="setStagedStatus(file, !staged[file.file])"
> >
<div :class="$style.listItemBody"> <n8n-text bold tag="strong">
<n8n-checkbox {{ i18n.baseText('settings.sourceControl.modals.push.workflowsToCommit') }}
:model-value="staged[file.file]" </n8n-text>
:class="$style.listItemCheckbox" <n8n-text v-show="workflowFiles.length > 0" tag="strong">
@update:model-value="setStagedStatus(file, !staged[file.file])" ({{ stagedWorkflowFiles.length }}/{{ workflowFiles.length }})
/> </n8n-text>
<div> </n8n-checkbox>
<n8n-text v-if="file.status === 'deleted'" color="text-light">
<span v-if="file.type === 'workflow'"> Deleted Workflow: </span> <n8n-checkbox
<span v-if="file.type === 'credential'"> Deleted Credential: </span> v-for="file in sortedFiles"
<strong>{{ file.name || file.id }}</strong> :key="file.file"
</n8n-text> :class="[
<n8n-text v-else bold> {{ file.name }} </n8n-text> 'scopedListItem',
<div v-if="file.updatedAt"> $style.listItem,
<n8n-text color="text-light" size="small"> { [$style.hiddenListItem]: defaultStagedFileTypes.includes(file.type) },
{{ renderUpdatedAt(file) }} ]"
</n8n-text> data-test-id="source-control-push-modal-file-checkbox"
</div> :model-value="staged[file.file]"
</div> @update:model-value="setStagedStatus(file, !staged[file.file])"
<div :class="$style.listItemStatus"> >
<n8n-badge <span>
v-if="workflowId === file.id && file.type === 'workflow'" <n8n-text v-if="file.status === 'deleted'" color="text-light">
class="mr-2xs" <span v-if="file.type === 'workflow'"> Deleted Workflow: </span>
> <span v-if="file.type === 'credential'"> Deleted Credential: </span>
Current workflow <strong>{{ file.name || file.id }}</strong>
</n8n-badge> </n8n-text>
<n8n-badge :theme="statusToBadgeThemeMap[file.status] || 'default'"> <n8n-text v-else bold> {{ file.name }} </n8n-text>
{{ getStatusText(file) }} <n8n-text
</n8n-badge> v-if="file.updatedAt"
</div> tag="p"
</div> class="mt-0"
</n8n-card> color="text-light"
size="small"
>
{{ renderUpdatedAt(file) }}
</n8n-text>
</span>
<span>
<n8n-badge v-if="workflowId === file.id && file.type === 'workflow'" class="mr-2xs">
Current workflow
</n8n-badge>
<n8n-badge :theme="statusToBadgeThemeMap[file.status] || 'default'">
{{ getStatusText(file) }}
</n8n-badge>
</span>
</n8n-checkbox>
</div> </div>
<n8n-notice v-else class="mt-0"> <n8n-notice v-else class="mt-0">
<i18n-t keypath="settings.sourceControl.modals.push.noWorkflowChanges"> <i18n-t keypath="settings.sourceControl.modals.push.noWorkflowChanges">
@ -380,11 +380,15 @@ function getStatusText(file: SourceControlAggregatedFile): string {
} }
.listItem { .listItem {
margin-top: var(--spacing-2xs); display: flex;
margin-bottom: var(--spacing-2xs); width: 100%;
align-items: center;
margin: var(--spacing-2xs) 0 var(--spacing-2xs);
padding: var(--spacing-xs);
cursor: pointer; cursor: pointer;
transition: border 0.3s ease; transition: border 0.3s ease;
padding: var(--spacing-xs); border-radius: var(--border-radius-large);
border: var(--border-base);
&:hover { &:hover {
border-color: var(--color-foreground-dark); border-color: var(--color-foreground-dark);
@ -397,22 +401,16 @@ function getStatusText(file: SourceControlAggregatedFile): string {
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
&.hiddenListItem {
display: none !important;
}
} }
.listItemBody { .selectAll {
display: flex; float: left;
flex-direction: row; clear: both;
align-items: center; margin: 0 0 var(--spacing-2xs);
}
.listItemCheckbox {
display: inline-flex !important;
margin-bottom: 0 !important;
margin-right: var(--spacing-2xs) !important;
}
.listItemStatus {
margin-left: auto;
} }
.footer { .footer {
@ -421,3 +419,12 @@ function getStatusText(file: SourceControlAggregatedFile): string {
justify-content: flex-end; justify-content: flex-end;
} }
</style> </style>
<style scoped lang="scss">
.scopedListItem :deep(.el-checkbox__label) {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
}
</style>

View file

@ -34,8 +34,9 @@ import {
StandardSQL, StandardSQL,
keywordCompletionSource, keywordCompletionSource,
} from '@n8n/codemirror-lang-sql'; } from '@n8n/codemirror-lang-sql';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme'; import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
const SQL_DIALECTS = { const SQL_DIALECTS = {
StandardSQL, StandardSQL,
@ -111,6 +112,7 @@ const extensions = computed(() => {
foldGutter(), foldGutter(),
dropCursor(), dropCursor(),
bracketMatching(), bracketMatching(),
mappingDropCursor(),
]); ]);
} }
return baseExtensions; return baseExtensions;
@ -178,11 +180,28 @@ function highlightLine(lineNumber: number | 'final') {
selection: { anchor: lineToHighlight.from }, selection: { anchor: lineToHighlight.from },
}); });
} }
async function onDrop(value: string, event: MouseEvent) {
if (!editor.value) return;
await dropInExpressionEditor(toRaw(editor.value), event, value);
}
</script> </script>
<template> <template>
<div :class="$style.sqlEditor"> <div :class="$style.sqlEditor">
<div ref="sqlEditor" :class="$style.codemirror" data-test-id="sql-editor-container"></div> <DraggableTarget type="mapping" :disabled="isReadOnly" @drop="onDrop">
<template #default="{ activeDrop, droppable }">
<div
ref="sqlEditor"
:class="[
$style.codemirror,
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
]"
data-test-id="sql-editor-container"
></div>
</template>
</DraggableTarget>
<slot name="suffix" /> <slot name="suffix" />
<InlineExpressionEditorOutput <InlineExpressionEditorOutput
v-if="!fullscreen" v-if="!fullscreen"
@ -202,4 +221,21 @@ function highlightLine(lineNumber: number | 'final') {
.codemirror { .codemirror {
height: 100%; height: 100%;
} }
.codemirror.droppable {
:global(.cm-editor) {
border-color: var(--color-ndv-droppable-parameter);
border-style: dashed;
border-width: 1.5px;
}
}
.codemirror.activeDrop {
:global(.cm-editor) {
border-color: var(--color-success);
border-style: solid;
cursor: grabbing;
border-width: 1px;
}
}
</style> </style>

View file

@ -54,6 +54,7 @@ describe('ParameterInput.vue', () => {
type: 'test', type: 'test',
typeVersion: 1, typeVersion: 1,
}, },
isNDVDataEmpty: vi.fn(() => false),
}; };
mockNodeTypesState = { mockNodeTypesState = {
allNodeTypes: [], allNodeTypes: [],

View file

@ -2,13 +2,19 @@ import { createComponentRenderer } from '@/__tests__/render';
import RunDataJsonSchema from '@/components/RunDataSchema.vue'; import RunDataJsonSchema from '@/components/RunDataSchema.vue';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { userEvent } from '@testing-library/user-event'; import { userEvent } from '@testing-library/user-event';
import { cleanup, within } from '@testing-library/vue'; import { cleanup, within, waitFor } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import { createTestNode, defaultNodeDescriptions } from '@/__tests__/mocks'; import {
import { SET_NODE_TYPE } from '@/constants'; createTestNode,
defaultNodeDescriptions,
mockNodeTypeDescription,
} from '@/__tests__/mocks';
import { IF_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import type { IWorkflowDb } from '@/Interface'; import type { IWorkflowDb } from '@/Interface';
import { NodeConnectionType, type IDataObject } from 'n8n-workflow';
import * as nodeHelpers from '@/composables/useNodeHelpers';
const mockNode1 = createTestNode({ const mockNode1 = createTestNode({
name: 'Set1', name: 'Set1',
@ -31,13 +37,20 @@ const disabledNode = createTestNode({
disabled: true, disabled: true,
}); });
const ifNode = createTestNode({
name: 'If',
type: IF_NODE_TYPE,
typeVersion: 1,
disabled: false,
});
async function setupStore() { async function setupStore() {
const workflow = mock<IWorkflowDb>({ const workflow = mock<IWorkflowDb>({
id: '123', id: '123',
name: 'Test Workflow', name: 'Test Workflow',
connections: {}, connections: {},
active: true, active: true,
nodes: [mockNode1, mockNode2, disabledNode], nodes: [mockNode1, mockNode2, disabledNode, ifNode],
}); });
const pinia = createPinia(); const pinia = createPinia();
@ -46,12 +59,33 @@ async function setupStore() {
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
nodeTypesStore.setNodeTypes(defaultNodeDescriptions); nodeTypesStore.setNodeTypes([
...defaultNodeDescriptions,
mockNodeTypeDescription({
name: IF_NODE_TYPE,
outputs: [NodeConnectionType.Main, NodeConnectionType.Main],
}),
]);
workflowsStore.workflow = workflow; workflowsStore.workflow = workflow;
return pinia; return pinia;
} }
function mockNodeOutputData(nodeName: string, data: IDataObject[], outputIndex = 0) {
const originalNodeHelpers = nodeHelpers.useNodeHelpers();
vi.spyOn(nodeHelpers, 'useNodeHelpers').mockImplementation(() => {
return {
...originalNodeHelpers,
getNodeInputData: vi.fn((node, _, output) => {
if (node.name === nodeName && output === outputIndex) {
return data.map((json) => ({ json }));
}
return [];
}),
};
});
}
describe('RunDataSchema.vue', () => { describe('RunDataSchema.vue', () => {
let renderComponent: ReturnType<typeof createComponentRenderer>; let renderComponent: ReturnType<typeof createComponentRenderer>;
@ -122,7 +156,7 @@ describe('RunDataSchema.vue', () => {
expect(within(nodes[1]).getByTestId('run-data-schema-node-schema')).toMatchSnapshot(); expect(within(nodes[1]).getByTestId('run-data-schema-node-schema')).toMatchSnapshot();
}); });
it('renders schema for in output pane', async () => { it('renders schema in output pane', async () => {
const { container } = renderComponent({ const { container } = renderComponent({
props: { props: {
nodes: [], nodes: [],
@ -183,6 +217,28 @@ describe('RunDataSchema.vue', () => {
); );
}); });
it('renders schema for correct output branch', async () => {
mockNodeOutputData(
'If',
[
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
],
1,
);
const { getByTestId } = renderComponent({
props: {
nodes: [{ name: 'If', indicies: [1], depth: 2 }],
},
});
await waitFor(() => {
expect(getByTestId('run-data-schema-node-name')).toHaveTextContent('If');
expect(getByTestId('run-data-schema-node-item-count')).toHaveTextContent('2 items');
expect(getByTestId('run-data-schema-node-schema')).toMatchSnapshot();
});
});
test.each([[[{ tx: false }, { tx: false }]], [[{ tx: '' }, { tx: '' }]], [[{ tx: [] }]]])( test.each([[[{ tx: false }, { tx: false }]], [[{ tx: '' }, { tx: '' }]], [[{ tx: [] }]]])(
'renders schema instead of showing no data for %o', 'renders schema instead of showing no data for %o',
(data) => { (data) => {

View file

@ -313,7 +313,6 @@ onBeforeUnmount(() => {
@open:contextmenu="onOpenContextMenuFromNode" @open:contextmenu="onOpenContextMenuFromNode"
> >
<NodeIcon <NodeIcon
v-if="nodeTypeDescription"
:node-type="nodeTypeDescription" :node-type="nodeTypeDescription"
:size="nodeIconSize" :size="nodeIconSize"
:shrink="false" :shrink="false"

View file

@ -4,7 +4,7 @@ import { NodeConnectionType } from 'n8n-workflow';
import { createCanvasNodeProvide } from '@/__tests__/data'; import { createCanvasNodeProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import { CanvasNodeRenderType } from '@/types'; import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
const renderComponent = createComponentRenderer(CanvasNodeDefault); const renderComponent = createComponentRenderer(CanvasNodeDefault);
@ -158,6 +158,36 @@ describe('CanvasNodeDefault', () => {
}); });
expect(getByText('Test Node').closest('.node')).not.toHaveClass('disabled'); expect(getByText('Test Node').closest('.node')).not.toHaveClass('disabled');
}); });
it('should render strike-through when node is disabled and has node input and output handles', () => {
const { container } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
disabled: true,
inputs: [{ type: NodeConnectionType.Main, index: 0 }],
outputs: [{ type: NodeConnectionType.Main, index: 0 }],
connections: {
[CanvasConnectionMode.Input]: {
[NodeConnectionType.Main]: [
[{ node: 'node', type: NodeConnectionType.Main, index: 0 }],
],
},
[CanvasConnectionMode.Output]: {
[NodeConnectionType.Main]: [
[{ node: 'node', type: NodeConnectionType.Main, index: 0 }],
],
},
},
},
}),
},
},
});
expect(container.querySelector('.disabledStrikeThrough')).toBeVisible();
});
}); });
describe('running', () => { describe('running', () => {

View file

@ -30,7 +30,14 @@ const {
hasIssues, hasIssues,
render, render,
} = useCanvasNode(); } = useCanvasNode();
const { mainOutputs, mainInputs, nonMainInputs, requiredNonMainInputs } = useNodeConnections({ const {
mainOutputs,
mainOutputConnections,
mainInputs,
mainInputConnections,
nonMainInputs,
requiredNonMainInputs,
} = useNodeConnections({
inputs, inputs,
outputs, outputs,
connections, connections,
@ -86,6 +93,15 @@ const dataTestId = computed(() => {
return `canvas-${type}-node`; return `canvas-${type}-node`;
}); });
const isStrikethroughVisible = computed(() => {
const isSingleMainInputNode =
mainInputs.value.length === 1 && mainInputConnections.value.length <= 1;
const isSingleMainOutputNode =
mainOutputs.value.length === 1 && mainOutputConnections.value.length <= 1;
return isDisabled.value && isSingleMainInputNode && isSingleMainOutputNode;
});
function openContextMenu(event: MouseEvent) { function openContextMenu(event: MouseEvent) {
emit('open:contextmenu', event); emit('open:contextmenu', event);
} }
@ -103,7 +119,7 @@ function openContextMenu(event: MouseEvent) {
</div> </div>
</N8nTooltip> </N8nTooltip>
<CanvasNodeStatusIcons :class="$style.statusIcons" /> <CanvasNodeStatusIcons :class="$style.statusIcons" />
<CanvasNodeDisabledStrikeThrough v-if="isDisabled" /> <CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" />
<div :class="$style.description"> <div :class="$style.description">
<div v-if="label" :class="$style.label"> <div v-if="label" :class="$style.label">
{{ label }} {{ label }}

View file

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

View file

@ -1,21 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, useCssModule } from 'vue'; import { computed, useCssModule } from 'vue';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { useCanvasNode } from '@/composables/useCanvasNode';
const $style = useCssModule(); const $style = useCssModule();
const { inputs, outputs, connections } = useCanvasNode();
const { mainInputConnections, mainOutputConnections } = useNodeConnections({
inputs,
outputs,
connections,
});
const isVisible = computed(
() => mainInputConnections.value.length === 1 && mainOutputConnections.value.length === 1,
);
const isSuccessStatus = computed( const isSuccessStatus = computed(
() => false, () => false,
// @TODO Implement this // @TODO Implement this
@ -31,7 +18,7 @@ const classes = computed(() => {
</script> </script>
<template> <template>
<div v-if="isVisible" :class="classes"></div> <div :class="classes"></div>
</template> </template>
<style lang="scss" module> <style lang="scss" module>

View file

@ -2,7 +2,11 @@
import { watch, computed, ref, onMounted } from 'vue'; import { watch, computed, ref, onMounted } from 'vue';
import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue'; import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue';
import GlobalExecutionsListItem from '@/components/executions/global/GlobalExecutionsListItem.vue'; import GlobalExecutionsListItem from '@/components/executions/global/GlobalExecutionsListItem.vue';
import { MODAL_CONFIRM } from '@/constants'; import {
EnterpriseEditionFeature,
EXECUTION_ANNOTATION_EXPERIMENT,
MODAL_CONFIRM,
} from '@/constants';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage'; import { useMessage } from '@/composables/useMessage';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
@ -13,6 +17,8 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { useExecutionsStore } from '@/stores/executions.store'; import { useExecutionsStore } from '@/stores/executions.store';
import type { PermissionsRecord } from '@/permissions'; import type { PermissionsRecord } from '@/permissions';
import { getResourcePermissions } from '@/permissions'; import { getResourcePermissions } from '@/permissions';
import { usePostHog } from '@/stores/posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -36,6 +42,8 @@ const i18n = useI18n();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const executionsStore = useExecutionsStore(); const executionsStore = useExecutionsStore();
const posthogStore = usePostHog();
const settingsStore = useSettingsStore();
const isMounted = ref(false); const isMounted = ref(false);
const allVisibleSelected = ref(false); const allVisibleSelected = ref(false);
@ -63,6 +71,12 @@ const workflows = computed<IWorkflowDb[]>(() => {
]; ];
}); });
const isAnnotationEnabled = computed(
() =>
settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters] &&
posthogStore.isFeatureEnabled(EXECUTION_ANNOTATION_EXPERIMENT),
);
watch( watch(
() => props.executions, () => props.executions,
() => { () => {
@ -109,10 +123,18 @@ function toggleSelectExecution(execution: ExecutionSummary) {
} }
async function handleDeleteSelected() { async function handleDeleteSelected() {
const deleteExecutions = await message.confirm( // Prepend the message with a note about annotations if the feature is enabled
const confirmationText = [
isAnnotationEnabled.value && i18n.baseText('executionsList.confirmMessage.annotationsNote'),
i18n.baseText('executionsList.confirmMessage.message', { i18n.baseText('executionsList.confirmMessage.message', {
interpolate: { count: selectedCount.value.toString() }, interpolate: { count: selectedCount.value.toString() },
}), }),
]
.filter(Boolean)
.join(' ');
const deleteExecutions = await message.confirm(
confirmationText,
i18n.baseText('executionsList.confirmMessage.headline'), i18n.baseText('executionsList.confirmMessage.headline'),
{ {
type: 'warning', type: 'warning',
@ -258,6 +280,26 @@ async function stopExecution(execution: ExecutionSummary) {
} }
async function deleteExecution(execution: ExecutionSummary) { async function deleteExecution(execution: ExecutionSummary) {
const hasAnnotation =
!!execution.annotation && (execution.annotation.vote || execution.annotation.tags.length > 0);
// Show a confirmation dialog if the execution has an annotation
if (hasAnnotation) {
const deleteConfirmed = await message.confirm(
i18n.baseText('executionsList.confirmMessage.annotatedExecutionMessage'),
i18n.baseText('executionDetails.confirmMessage.headline'),
{
type: 'warning',
confirmButtonText: i18n.baseText('executionDetails.confirmMessage.confirmButtonText'),
cancelButtonText: '',
},
);
if (deleteConfirmed !== MODAL_CONFIRM) {
return;
}
}
try { try {
await executionsStore.deleteExecutions({ ids: [execution.id] }); await executionsStore.deleteExecutions({ ids: [execution.id] });

View file

@ -72,9 +72,23 @@ const isAnnotationEnabled = computed(
posthogStore.isFeatureEnabled(EXECUTION_ANNOTATION_EXPERIMENT), posthogStore.isFeatureEnabled(EXECUTION_ANNOTATION_EXPERIMENT),
); );
const hasAnnotation = computed(
() =>
!!props.execution?.annotation &&
(props.execution?.annotation.vote || props.execution?.annotation.tags.length > 0),
);
async function onDeleteExecution(): Promise<void> { async function onDeleteExecution(): Promise<void> {
const deleteConfirmed = await message.confirm( // Prepend the message with a note about annotations if they exist
const confirmationText = [
hasAnnotation.value && locale.baseText('executionDetails.confirmMessage.annotationsNote'),
locale.baseText('executionDetails.confirmMessage.message'), locale.baseText('executionDetails.confirmMessage.message'),
]
.filter(Boolean)
.join(' ');
const deleteConfirmed = await message.confirm(
confirmationText,
locale.baseText('executionDetails.confirmMessage.headline'), locale.baseText('executionDetails.confirmMessage.headline'),
{ {
type: 'warning', type: 'warning',

View file

@ -102,15 +102,6 @@ describe('useCanvasOperations', () => {
expect(result).toBe(expectedDescription); expect(result).toBe(expectedDescription);
}); });
it('should throw an error when node type does not exist', () => {
const type = 'nonexistentType';
const { requireNodeTypeDescription } = useCanvasOperations({ router });
expect(() => {
requireNodeTypeDescription(type);
}).toThrow();
});
it('should return node type description when only type is provided and it exists', () => { it('should return node type description when only type is provided and it exists', () => {
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const type = 'testTypeWithoutVersion'; const type = 'testTypeWithoutVersion';
@ -123,6 +114,25 @@ describe('useCanvasOperations', () => {
expect(result).toBe(expectedDescription); expect(result).toBe(expectedDescription);
}); });
it("should return placeholder node type description if node type doesn't exist", () => {
const type = 'nonexistentType';
const { requireNodeTypeDescription } = useCanvasOperations({ router });
const result = requireNodeTypeDescription(type);
expect(result).toEqual({
name: type,
displayName: type,
description: '',
defaults: {},
group: [],
inputs: [],
outputs: [],
properties: [],
version: 1,
});
});
}); });
describe('addNode', () => { describe('addNode', () => {
@ -616,7 +626,6 @@ describe('useCanvasOperations', () => {
deleteNode(id, { trackHistory: true }); deleteNode(id, { trackHistory: true });
expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(id); expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(id);
expect(workflowsStore.removeNodeConnectionsById).toHaveBeenCalledWith(id);
expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(id); expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(id);
expect(historyStore.pushCommandToUndo).toHaveBeenCalledWith(new RemoveNodeCommand(node)); expect(historyStore.pushCommandToUndo).toHaveBeenCalledWith(new RemoveNodeCommand(node));
}); });
@ -644,7 +653,6 @@ describe('useCanvasOperations', () => {
deleteNode(id, { trackHistory: false }); deleteNode(id, { trackHistory: false });
expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(id); expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(id);
expect(workflowsStore.removeNodeConnectionsById).toHaveBeenCalledWith(id);
expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(id); expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(id);
expect(historyStore.pushCommandToUndo).not.toHaveBeenCalled(); expect(historyStore.pushCommandToUndo).not.toHaveBeenCalled();
}); });
@ -714,7 +722,6 @@ describe('useCanvasOperations', () => {
deleteNode(nodes[1].id); deleteNode(nodes[1].id);
expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(nodes[1].id); expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(nodes[1].id);
expect(workflowsStore.removeNodeConnectionsById).toHaveBeenCalledWith(nodes[1].id);
expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(nodes[1].id); expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(nodes[1].id);
expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(nodes[1].id); expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(nodes[1].id);
}); });
@ -1356,6 +1363,62 @@ describe('useCanvasOperations', () => {
}); });
}); });
describe('deleteConnectionsByNodeId', () => {
it('should delete all connections for a given node ID', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const { deleteConnectionsByNodeId } = useCanvasOperations({ router });
const node1 = createTestNode({ id: 'node1', name: 'Node 1' });
const node2 = createTestNode({ id: 'node2', name: 'Node 1' });
workflowsStore.workflow.connections = {
[node1.name]: {
[NodeConnectionType.Main]: [
[{ node: node2.name, type: NodeConnectionType.Main, index: 0 }],
],
},
node2: {
[NodeConnectionType.Main]: [
[{ node: node1.name, type: NodeConnectionType.Main, index: 0 }],
],
},
};
workflowsStore.getNodeById.mockReturnValue(node1);
workflowsStore.getNodeByName.mockReturnValueOnce(node1).mockReturnValueOnce(node2);
deleteConnectionsByNodeId(node1.id);
expect(workflowsStore.removeConnection).toHaveBeenCalledWith({
connection: [
{ node: node1.name, type: NodeConnectionType.Main, index: 0 },
{ node: node2.name, type: NodeConnectionType.Main, index: 0 },
],
});
expect(workflowsStore.removeConnection).toHaveBeenCalledWith({
connection: [
{ node: node2.name, type: NodeConnectionType.Main, index: 0 },
{ node: node1.name, type: NodeConnectionType.Main, index: 0 },
],
});
expect(workflowsStore.workflow.connections[node1.name]).toBeUndefined();
});
it('should not delete connections if node ID does not exist', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const { deleteConnectionsByNodeId } = useCanvasOperations({ router });
const nodeId = 'nonexistent';
workflowsStore.getNodeById.mockReturnValue(undefined);
deleteConnectionsByNodeId(nodeId);
expect(workflowsStore.removeConnection).not.toHaveBeenCalled();
});
});
describe('duplicateNodes', () => { describe('duplicateNodes', () => {
it('should duplicate nodes', async () => { it('should duplicate nodes', async () => {
const workflowsStore = mockedStore(useWorkflowsStore); const workflowsStore = mockedStore(useWorkflowsStore);

View file

@ -236,7 +236,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
await renameNode(currentName, previousName); await renameNode(currentName, previousName);
} }
function connectAdjacentNodes(id: string) { function connectAdjacentNodes(id: string, { trackHistory = false } = {}) {
const node = workflowsStore.getNodeById(id); const node = workflowsStore.getNodeById(id);
if (!node) { if (!node) {
@ -262,6 +262,23 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
if (!outgoingNodeId) continue; if (!outgoingNodeId) continue;
if (trackHistory) {
historyStore.pushCommandToUndo(
new AddConnectionCommand([
{
node: incomingConnection.node,
type,
index: 0,
},
{
node: outgoingConnection.node,
type,
index: 0,
},
]),
);
}
createConnection({ createConnection({
source: incomingNodeId, source: incomingNodeId,
sourceHandle: createCanvasConnectionHandleString({ sourceHandle: createCanvasConnectionHandleString({
@ -289,8 +306,13 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
historyStore.startRecordingUndo(); historyStore.startRecordingUndo();
} }
connectAdjacentNodes(id); if (uiStore.lastInteractedWithNodeId === id) {
workflowsStore.removeNodeConnectionsById(id); uiStore.lastInteractedWithNodeId = null;
}
connectAdjacentNodes(id, { trackHistory });
deleteConnectionsByNodeId(id, { trackHistory, trackBulk: false });
workflowsStore.removeNodeExecutionDataById(id); workflowsStore.removeNodeExecutionDataById(id);
workflowsStore.removeNodeById(id); workflowsStore.removeNodeById(id);
@ -423,16 +445,23 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
historyStore.stopRecordingUndo(); historyStore.stopRecordingUndo();
} }
function requireNodeTypeDescription(type: INodeUi['type'], version?: INodeUi['typeVersion']) { function requireNodeTypeDescription(
const nodeTypeDescription = nodeTypesStore.getNodeType(type, version); type: INodeUi['type'],
if (!nodeTypeDescription) { version?: INodeUi['typeVersion'],
throw new Error( ): INodeTypeDescription {
i18n.baseText('nodeView.showMessage.addNodeButton.message', { return (
interpolate: { nodeTypeName: type }, nodeTypesStore.getNodeType(type, version) ?? {
}), properties: [],
); displayName: type,
} name: type,
return nodeTypeDescription; group: [],
description: '',
version: version ?? 1,
defaults: {},
inputs: [],
outputs: [],
}
);
} }
async function addNodes( async function addNodes(
@ -1135,6 +1164,72 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
deleteConnection(mapLegacyConnectionToCanvasConnection(sourceNode, targetNode, connection)); deleteConnection(mapLegacyConnectionToCanvasConnection(sourceNode, targetNode, connection));
} }
function deleteConnectionsByNodeId(
targetNodeId: string,
{ trackHistory = false, trackBulk = true } = {},
) {
const targetNode = workflowsStore.getNodeById(targetNodeId);
if (!targetNode) {
return;
}
if (trackHistory && trackBulk) {
historyStore.startRecordingUndo();
}
const connections = workflowsStore.workflow.connections;
for (const nodeName of Object.keys(connections)) {
const node = workflowsStore.getNodeByName(nodeName);
if (!node) {
continue;
}
for (const type of Object.keys(connections[nodeName])) {
for (const index of Object.keys(connections[nodeName][type])) {
for (const connectionIndex of Object.keys(
connections[nodeName][type][parseInt(index, 10)],
)) {
const connectionData =
connections[nodeName][type][parseInt(index, 10)][parseInt(connectionIndex, 10)];
if (!connectionData) {
continue;
}
const connectionDataNode = workflowsStore.getNodeByName(connectionData.node);
if (
connectionDataNode &&
(connectionDataNode.id === targetNode.id || node.name === targetNode.name)
) {
deleteConnection(
{
source: node.id,
sourceHandle: createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Output,
type: type as NodeConnectionType,
index: parseInt(index, 10),
}),
target: connectionDataNode.id,
targetHandle: createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Input,
type: connectionData.type as NodeConnectionType,
index: connectionData.index,
}),
},
{ trackHistory, trackBulk: false },
);
}
}
}
}
}
delete workflowsStore.workflow.connections[targetNode.name];
if (trackHistory && trackBulk) {
historyStore.stopRecordingUndo();
}
}
function deleteConnection( function deleteConnection(
connection: Connection, connection: Connection,
{ trackHistory = false, trackBulk = true } = {}, { trackHistory = false, trackBulk = true } = {},
@ -1777,6 +1872,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
revertCreateConnection, revertCreateConnection,
deleteConnection, deleteConnection,
revertDeleteConnection, revertDeleteConnection,
deleteConnectionsByNodeId,
isConnectionAllowed, isConnectionAllowed,
importWorkflowData, importWorkflowData,
fetchWorkflowDataFromUrl, fetchWorkflowDataFromUrl,

View file

@ -1,4 +1,5 @@
import { createApp } from 'vue'; import { createApp } from 'vue';
import * as Sentry from '@sentry/vue';
import '@vue-flow/core/dist/style.css'; import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css'; import '@vue-flow/core/dist/theme-default.css';
@ -34,6 +35,11 @@ const pinia = createPinia();
const app = createApp(App); const app = createApp(App);
if (window.sentry?.dsn) {
const { dsn, release, environment } = window.sentry;
Sentry.init({ app, dsn, release, environment });
}
app.use(TelemetryPlugin); app.use(TelemetryPlugin);
app.use(PiniaVuePlugin); app.use(PiniaVuePlugin);
app.use(I18nPlugin); app.use(I18nPlugin);

View file

@ -1,8 +1,8 @@
import { EditorSelection, StateEffect, StateField, type Extension } from '@codemirror/state';
import { ViewPlugin, type EditorView, type ViewUpdate } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { unwrapExpression } from '@/utils/expressions'; import { unwrapExpression } from '@/utils/expressions';
import { syntaxTree } from '@codemirror/language';
import { EditorSelection, StateEffect, StateField, type Extension } from '@codemirror/state';
import { ViewPlugin, type EditorView, type ViewUpdate } from '@codemirror/view';
const setDropCursorPos = StateEffect.define<number | null>({ const setDropCursorPos = StateEffect.define<number | null>({
map(pos, mapping) { map(pos, mapping) {
@ -121,20 +121,10 @@ function eventToCoord(event: MouseEvent): { x: number; y: number } {
return { x: event.clientX, y: event.clientY }; return { x: event.clientX, y: event.clientY };
} }
export async function dropInEditor(view: EditorView, event: MouseEvent, value: string) { function dropValueInEditor(view: EditorView, pos: number, value: string) {
const dropPos = view.posAtCoords(eventToCoord(event), false); const changes = view.state.changes({ from: pos, insert: value });
const anchor = changes.mapPos(pos, -1);
const node = syntaxTree(view.state).resolve(dropPos); const head = changes.mapPos(pos, 1);
let valueToInsert = value;
// We are already in an expression, do not insert brackets
if (node.name === 'Resolvable') {
valueToInsert = unwrapExpression(value);
}
const changes = view.state.changes({ from: dropPos, insert: valueToInsert });
const anchor = changes.mapPos(dropPos, -1);
const head = changes.mapPos(dropPos, 1);
const selection = EditorSelection.single(anchor, head); const selection = EditorSelection.single(anchor, head);
view.dispatch({ view.dispatch({
@ -144,10 +134,29 @@ export async function dropInEditor(view: EditorView, event: MouseEvent, value: s
}); });
setTimeout(() => view.focus()); setTimeout(() => view.focus());
return selection; return selection;
} }
export async function dropInExpressionEditor(view: EditorView, event: MouseEvent, value: string) {
const dropPos = view.posAtCoords(eventToCoord(event), false);
const node = syntaxTree(view.state).resolve(dropPos);
let valueToInsert = value;
// We are already in an expression, do not insert brackets
if (node.name === 'Resolvable') {
valueToInsert = unwrapExpression(value);
}
return dropValueInEditor(view, dropPos, valueToInsert);
}
export async function dropInCodeEditor(view: EditorView, event: MouseEvent, value: string) {
const dropPos = view.posAtCoords(eventToCoord(event), false);
const valueToInsert = unwrapExpression(value);
return dropValueInEditor(view, dropPos, valueToInsert);
}
export function mappingDropCursor(): Extension { export function mappingDropCursor(): Extension {
return [dropCursorPos, drawDropCursor]; return [dropCursorPos, drawDropCursor];
} }

View file

@ -649,6 +649,7 @@
"executionDetails.confirmMessage.confirmButtonText": "Yes, delete", "executionDetails.confirmMessage.confirmButtonText": "Yes, delete",
"executionDetails.confirmMessage.headline": "Delete Execution?", "executionDetails.confirmMessage.headline": "Delete Execution?",
"executionDetails.confirmMessage.message": "Are you sure that you want to delete the current execution?", "executionDetails.confirmMessage.message": "Are you sure that you want to delete the current execution?",
"executionDetails.confirmMessage.annotationsNote": "By deleting this you will also remove the associated annotation data.",
"executionDetails.deleteExecution": "Delete this execution", "executionDetails.deleteExecution": "Delete this execution",
"executionDetails.executionFailed": "Execution failed", "executionDetails.executionFailed": "Execution failed",
"executionDetails.executionFailed.recoveredNodeTitle": "Cant show data", "executionDetails.executionFailed.recoveredNodeTitle": "Cant show data",
@ -689,6 +690,8 @@
"executionsList.confirmMessage.confirmButtonText": "Yes, delete", "executionsList.confirmMessage.confirmButtonText": "Yes, delete",
"executionsList.confirmMessage.headline": "Delete Executions?", "executionsList.confirmMessage.headline": "Delete Executions?",
"executionsList.confirmMessage.message": "Are you sure that you want to delete the {count} selected execution(s)?", "executionsList.confirmMessage.message": "Are you sure that you want to delete the {count} selected execution(s)?",
"executionsList.confirmMessage.annotationsNote": "By deleting these executions you will also remove the associated annotation data.",
"executionsList.confirmMessage.annotatedExecutionMessage": "By deleting this you will also remove the associated annotation data. Are you sure that you want to delete the selected execution?",
"executionsList.clearSelection": "Clear selection", "executionsList.clearSelection": "Clear selection",
"executionsList.error": "Error", "executionsList.error": "Error",
"executionsList.filters": "Filters", "executionsList.filters": "Filters",

View file

@ -1,6 +1,7 @@
import type { VNode, ComponentPublicInstance } from 'vue'; import type { VNode, ComponentPublicInstance } from 'vue';
import type { PartialDeep } from 'type-fest'; import type { PartialDeep } from 'type-fest';
import type { ExternalHooks } from '@/types/externalHooks'; import type { ExternalHooks } from '@/types/externalHooks';
import type { FrontendSettings } from '@n8n/api-types';
export {}; export {};
@ -17,6 +18,7 @@ declare global {
interface Window { interface Window {
BASE_PATH: string; BASE_PATH: string;
REST_ENDPOINT: string; REST_ENDPOINT: string;
sentry?: { dsn?: string; environment: string; release: string };
n8nExternalHooks?: PartialDeep<ExternalHooks>; n8nExternalHooks?: PartialDeep<ExternalHooks>;
preventNodeViewBeforeUnload?: boolean; preventNodeViewBeforeUnload?: boolean;
maxPinnedDataSize?: number; maxPinnedDataSize?: number;

View file

@ -1525,8 +1525,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
} }
removeNode(node); removeNode(node);
// @TODO When removing node connected between two nodes, create a connection between them
} }
function removeNodeConnectionsById(nodeId: string): void { function removeNodeConnectionsById(nodeId: string): void {

View file

@ -859,6 +859,7 @@ async function onAddNodesAndConnections(
const mappedConnections: CanvasConnectionCreateData[] = connections.map(({ from, to }) => { const mappedConnections: CanvasConnectionCreateData[] = connections.map(({ from, to }) => {
const fromNode = editableWorkflow.value.nodes[offsetIndex + from.nodeIndex]; const fromNode = editableWorkflow.value.nodes[offsetIndex + from.nodeIndex];
const toNode = editableWorkflow.value.nodes[offsetIndex + to.nodeIndex]; const toNode = editableWorkflow.value.nodes[offsetIndex + to.nodeIndex];
const type = from.type ?? to.type ?? NodeConnectionType.Main;
return { return {
source: fromNode.id, source: fromNode.id,
@ -866,11 +867,11 @@ async function onAddNodesAndConnections(
data: { data: {
source: { source: {
index: from.outputIndex ?? 0, index: from.outputIndex ?? 0,
type: NodeConnectionType.Main, type,
}, },
target: { target: {
index: to.inputIndex ?? 0, index: to.inputIndex ?? 0,
type: NodeConnectionType.Main, type,
}, },
}, },
}; };

View file

@ -93,13 +93,13 @@ if (release && authToken) {
sentryVitePlugin({ sentryVitePlugin({
org: 'n8nio', org: 'n8nio',
project: 'instance-frontend', project: 'instance-frontend',
// Specify the directory containing build artifacts
include: './dist',
// Auth tokens can be obtained from https://sentry.io/settings/account/api/auth-tokens/ // Auth tokens can be obtained from https://sentry.io/settings/account/api/auth-tokens/
// and needs the `project:releases` and `org:read` scopes // and needs the `project:releases` and `org:read` scopes
authToken, authToken,
telemetry: false, telemetry: false,
release, release: {
name: release,
},
}), }),
); );
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-node-dev", "name": "n8n-node-dev",
"version": "1.60.0", "version": "1.61.0",
"description": "CLI to simplify n8n credentials/node development", "description": "CLI to simplify n8n credentials/node development",
"main": "dist/src/index", "main": "dist/src/index",
"types": "dist/src/index.d.ts", "types": "dist/src/index.d.ts",

View file

@ -18,7 +18,7 @@ export async function pageList(
value: page.id, value: page.id,
url: `https://facebook.com/${page.id}`, url: `https://facebook.com/${page.id}`,
})), })),
paginationToken: paging?.cursors?.after, paginationToken: paging?.next ? paging?.cursors?.after : undefined,
}; };
} }
@ -37,6 +37,6 @@ export async function formList(
name: form.name, name: form.name,
value: form.id, value: form.id,
})), })),
paginationToken: paging?.cursors?.after, paginationToken: paging?.next ? paging?.cursors?.after : undefined,
}; };
} }

View file

@ -2,7 +2,7 @@ import type { GenericValue } from 'n8n-workflow';
export type BaseFacebookResponse<TData> = { data: TData }; export type BaseFacebookResponse<TData> = { data: TData };
export type BasePaginatedFacebookResponse<TData> = BaseFacebookResponse<TData> & { export type BasePaginatedFacebookResponse<TData> = BaseFacebookResponse<TData> & {
paging: { cursors: { before?: string; after?: string } }; paging: { cursors: { before?: string; after?: string }; next?: string };
}; };
export type FacebookAppWebhookSubscriptionsResponse = BaseFacebookResponse< export type FacebookAppWebhookSubscriptionsResponse = BaseFacebookResponse<

View file

@ -17,6 +17,6 @@
}, },
"alias": ["SFTP", "FTP", "Binary", "File", "Transfer"], "alias": ["SFTP", "FTP", "Binary", "File", "Transfer"],
"subcategories": { "subcategories": {
"Core Nodes": ["Files"] "Core Nodes": ["Files", "Helpers"]
} }
} }

View file

@ -143,6 +143,14 @@ const properties: INodeProperties[] = [
description: description:
"Whether to use BigQuery's legacy SQL dialect for this query. If set to false, the query will use BigQuery's standard SQL.", "Whether to use BigQuery's legacy SQL dialect for this query. If set to false, the query will use BigQuery's standard SQL.",
}, },
{
displayName: 'Return Integers as Numbers',
name: 'returnAsNumbers',
type: 'boolean',
default: false,
description:
'Whether all integer values will be returned as numbers. If set to false, all integer values will be returned as strings.',
},
], ],
}, },
]; ];
@ -180,6 +188,7 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
timeoutMs?: number; timeoutMs?: number;
rawOutput?: boolean; rawOutput?: boolean;
useLegacySql?: boolean; useLegacySql?: boolean;
returnAsNumbers?: boolean;
}; };
const projectId = this.getNodeParameter('projectId', i, undefined, { const projectId = this.getNodeParameter('projectId', i, undefined, {
@ -263,6 +272,29 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
qs, qs,
); );
if (body.returnAsNumbers === true) {
const numericDataTypes = ['INTEGER', 'NUMERIC', 'FLOAT', 'BIGNUMERIC']; // https://cloud.google.com/bigquery/docs/schemas#standard_sql_data_types
const schema: IDataObject = queryResponse?.schema as IDataObject;
const schemaFields: IDataObject[] = schema.fields as IDataObject[];
const schemaDataTypes: string[] = schemaFields?.map(
(field: IDataObject) => field.type as string,
);
const rows: IDataObject[] = queryResponse.rows as IDataObject[];
for (const row of rows) {
if (!row?.f || !Array.isArray(row.f)) continue;
row.f.forEach((entry: IDataObject, index: number) => {
if (entry && typeof entry === 'object' && 'v' in entry) {
// Skip this row if it's null or doesn't have 'f' as an array
const value = entry.v;
if (numericDataTypes.includes(schemaDataTypes[index])) {
entry.v = Number(value);
}
}
});
}
}
returnData.push(...prepareOutput.call(this, queryResponse, i, raw, includeSchema)); returnData.push(...prepareOutput.call(this, queryResponse, i, raw, includeSchema));
} else { } else {
jobs.push({ jobId, projectId, i, raw, includeSchema, location }); jobs.push({ jobId, projectId, i, raw, includeSchema, location });

View file

@ -0,0 +1,59 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { execute } from '../../../v2/actions/sheet/append.operation';
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
describe('Google Sheet - Append', () => {
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
let mockGoogleSheet: MockProxy<GoogleSheet>;
beforeEach(() => {
mockExecuteFunctions = mock<IExecuteFunctions>();
mockGoogleSheet = mock<GoogleSheet>();
});
it('should insert input data if sheet is empty', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: {
row_number: 3,
name: 'NEW NAME',
text: 'NEW TEXT',
},
pairedItem: {
item: 0,
input: undefined,
},
},
]);
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // valueInputMode
.mockReturnValueOnce({}); // options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('defineBelow'); // dataMode
mockGoogleSheet.getData.mockResolvedValueOnce(undefined);
mockGoogleSheet.getData.mockResolvedValueOnce(undefined);
mockGoogleSheet.updateRows.mockResolvedValueOnce(undefined);
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.updateRows).toHaveBeenCalledWith('Sheet1', [['name', 'text']], 'RAW', 1);
expect(mockGoogleSheet.appendEmptyRowsOrColumns).toHaveBeenCalledWith('1234', 1, 0);
expect(mockGoogleSheet.appendSheetData).toHaveBeenCalledWith({
inputData: [{ name: 'NEW NAME', text: 'NEW TEXT' }],
keyRowIndex: 1,
lastRow: 2,
range: 'Sheet1',
valueInputMode: 'USER_ENTERED',
});
});
});

View file

@ -0,0 +1,98 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { execute } from '../../../v2/actions/sheet/appendOrUpdate.operation';
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
describe('Google Sheet - Append or Update', () => {
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
let mockGoogleSheet: MockProxy<GoogleSheet>;
beforeEach(() => {
mockExecuteFunctions = mock<IExecuteFunctions>();
mockGoogleSheet = mock<GoogleSheet>();
});
it('should insert input data if sheet is empty', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: {
row_number: 3,
name: 'NEW NAME',
text: 'NEW TEXT',
},
pairedItem: {
item: 0,
input: undefined,
},
},
]);
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // valueInputMode
.mockReturnValueOnce({}); // options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('defineBelow'); // dataMode
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce([]); // columns.schema
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(['row_number']); // columnsToMatchOn
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>());
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce([]); // columns.matchingColumns
mockGoogleSheet.getData.mockResolvedValueOnce(undefined);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [
{
row_number: 3,
name: 'NEW NAME',
text: 'NEW TEXT',
},
],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.getColumnValues).toHaveBeenCalledWith({
dataStartRowIndex: 1,
keyIndex: -1,
range: 'Sheet1!A:Z',
sheetData: [['name', 'text']],
valueRenderMode: 'UNFORMATTED_VALUE',
});
expect(mockGoogleSheet.updateRows).toHaveBeenCalledWith(
'Sheet1',
[['name', 'text']],
'USER_ENTERED',
1,
);
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith({
columnNamesList: [['name', 'text']],
columnValuesList: [],
dataStartRowIndex: 1,
indexKey: 'row_number',
inputData: [{ name: 'NEW NAME', row_number: 3, text: 'NEW TEXT' }],
keyRowIndex: 0,
range: 'Sheet1!A:Z',
upsert: true,
valueRenderMode: 'UNFORMATTED_VALUE',
});
expect(mockGoogleSheet.appendEmptyRowsOrColumns).toHaveBeenCalledWith('1234', 1, 0);
expect(mockGoogleSheet.appendSheetData).toHaveBeenCalledWith({
columnNamesList: [['name', 'text']],
inputData: [{ name: 'NEW NAME', row_number: 3, text: 'NEW TEXT' }],
keyRowIndex: 1,
lastRow: 2,
range: 'Sheet1!A:Z',
valueInputMode: 'USER_ENTERED',
});
});
});

View file

@ -0,0 +1,104 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { execute } from '../../../v2/actions/sheet/update.operation';
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
describe('Google Sheet - Update', () => {
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
let mockGoogleSheet: MockProxy<GoogleSheet>;
beforeEach(() => {
mockExecuteFunctions = mock<IExecuteFunctions>();
mockGoogleSheet = mock<GoogleSheet>();
});
it('should update by row_number and not insert it as a new column', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: {
row_number: 3,
name: 'NEW NAME',
text: 'NEW TEXT',
},
pairedItem: {
item: 0,
input: undefined,
},
},
]);
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // valueInputMode
.mockReturnValueOnce({}); // options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(['row_number']); // columnsToMatchOn
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('autoMapInputData'); // dataMode
mockGoogleSheet.getData.mockResolvedValueOnce([
['id', 'name', 'text'],
['1', 'a', 'a'],
['2', 'x', 'x'],
['3', 'b', 'b'],
]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdatingByRowNumber.mockReturnValueOnce({
updateData: [
{
range: 'Sheet1!B3',
values: [['NEW NAME']],
},
{
range: 'Sheet1!C3',
values: [['NEW TEXT']],
},
],
});
mockGoogleSheet.batchUpdate.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1');
expect(mockGoogleSheet.getData).toHaveBeenCalledWith('Sheet1', 'FORMATTED_VALUE');
expect(mockGoogleSheet.getColumnValues).toHaveBeenCalledWith({
range: 'Sheet1!A:Z',
keyIndex: -1,
dataStartRowIndex: 1,
valueRenderMode: 'UNFORMATTED_VALUE',
sheetData: [
['id', 'name', 'text'],
['1', 'a', 'a'],
['2', 'x', 'x'],
['3', 'b', 'b'],
],
});
expect(mockGoogleSheet.prepareDataForUpdatingByRowNumber).toHaveBeenCalledWith(
[
{
row_number: 3,
name: 'NEW NAME',
text: 'NEW TEXT',
},
],
'Sheet1!A:Z',
[['id', 'name', 'text']],
);
expect(mockGoogleSheet.batchUpdate).toHaveBeenCalledWith(
[
{
range: 'Sheet1!B3',
values: [['NEW NAME']],
},
{
range: 'Sheet1!C3',
values: [['NEW TEXT']],
},
],
'USER_ENTERED',
);
});
});

View file

@ -211,7 +211,7 @@ export async function execute(
): Promise<INodeExecutionData[]> { ): Promise<INodeExecutionData[]> {
const items = this.getInputData(); const items = this.getInputData();
const nodeVersion = this.getNode().typeVersion; const nodeVersion = this.getNode().typeVersion;
const dataMode = let dataMode =
nodeVersion < 4 nodeVersion < 4
? (this.getNodeParameter('dataMode', 0) as string) ? (this.getNodeParameter('dataMode', 0) as string)
: (this.getNodeParameter('columns.mappingMode', 0) as string); : (this.getNodeParameter('columns.mappingMode', 0) as string);
@ -228,6 +228,10 @@ export async function execute(
const sheetData = await sheet.getData(range, 'FORMATTED_VALUE'); const sheetData = await sheet.getData(range, 'FORMATTED_VALUE');
if (sheetData === undefined || !sheetData.length) {
dataMode = 'autoMapInputData';
}
if (nodeVersion >= 4.4 && dataMode !== 'autoMapInputData') { if (nodeVersion >= 4.4 && dataMode !== 'autoMapInputData') {
//not possible to refresh columns when mode is autoMapInputData //not possible to refresh columns when mode is autoMapInputData
if (sheetData?.[keyRowIndex - 1] === undefined) { if (sheetData?.[keyRowIndex - 1] === undefined) {

View file

@ -257,7 +257,7 @@ export async function execute(
} }
} }
const dataMode = let dataMode =
nodeVersion < 4 nodeVersion < 4
? (this.getNodeParameter('dataMode', 0) as string) ? (this.getNodeParameter('dataMode', 0) as string)
: (this.getNodeParameter('columns.mappingMode', 0) as string); : (this.getNodeParameter('columns.mappingMode', 0) as string);
@ -267,10 +267,14 @@ export async function execute(
const sheetData = (await sheet.getData(sheetName, 'FORMATTED_VALUE')) ?? []; const sheetData = (await sheet.getData(sheetName, 'FORMATTED_VALUE')) ?? [];
if (!sheetData[keyRowIndex] && dataMode !== 'autoMapInputData') { if (!sheetData[keyRowIndex] && dataMode !== 'autoMapInputData') {
throw new NodeOperationError( if (!sheetData.length) {
this.getNode(), dataMode = 'autoMapInputData';
`Could not retrieve the column names from row ${keyRowIndex + 1}`, } else {
); throw new NodeOperationError(
this.getNode(),
`Could not retrieve the column names from row ${keyRowIndex + 1}`,
);
}
} }
columnNames = sheetData[keyRowIndex] ?? []; columnNames = sheetData[keyRowIndex] ?? [];

View file

@ -307,11 +307,11 @@ export async function execute(
if (handlingExtraDataOption === 'ignoreIt') { if (handlingExtraDataOption === 'ignoreIt') {
inputData.push(items[i].json); inputData.push(items[i].json);
} }
if (handlingExtraDataOption === 'error' && columnsToMatchOn[0] !== 'row_number') { if (handlingExtraDataOption === 'error') {
Object.keys(items[i].json).forEach((key) => errorOnUnexpectedColumn(key, i)); Object.keys(items[i].json).forEach((key) => errorOnUnexpectedColumn(key, i));
inputData.push(items[i].json); inputData.push(items[i].json);
} }
if (handlingExtraDataOption === 'insertInNewColumn' && columnsToMatchOn[0] !== 'row_number') { if (handlingExtraDataOption === 'insertInNewColumn') {
Object.keys(items[i].json).forEach(addNewColumn); Object.keys(items[i].json).forEach(addNewColumn);
inputData.push(items[i].json); inputData.push(items[i].json);
} }

View file

@ -26,6 +26,13 @@ export const versionDescription: INodeTypeDescription = {
whenToDisplay: 'beforeExecution', whenToDisplay: 'beforeExecution',
location: 'outputPane', location: 'outputPane',
}, },
{
message: 'No columns found in Google Sheet. All rows will be appended',
displayCondition:
'={{ ["appendOrUpdate", "append"].includes($parameter["operation"]) && $parameter?.columns?.mappingMode === "defineBelow" && !$parameter?.columns?.schema?.length }}',
whenToDisplay: 'beforeExecution',
location: 'outputPane',
},
], ],
credentials: [ credentials: [
{ {

View file

@ -327,7 +327,7 @@ export class MySqlV1 implements INodeType {
{ itemData: { item: index } }, { itemData: { item: index } },
); );
collection.push(...executionData); collection = collection.concat(executionData);
return collection; return collection;
}, },

View file

@ -141,7 +141,7 @@ export function prepareOutput(
) => NodeExecutionWithMetadata[], ) => NodeExecutionWithMetadata[],
itemData: IPairedItemData | IPairedItemData[], itemData: IPairedItemData | IPairedItemData[],
) { ) {
const returnData: INodeExecutionData[] = []; let returnData: INodeExecutionData[] = [];
if (options.detailedOutput) { if (options.detailedOutput) {
response.forEach((entry, index) => { response.forEach((entry, index) => {
@ -154,7 +154,7 @@ export function prepareOutput(
itemData, itemData,
}); });
returnData.push(...executionData); returnData = returnData.concat(executionData);
}); });
} else { } else {
response response
@ -164,7 +164,7 @@ export function prepareOutput(
itemData: Array.isArray(itemData) ? itemData[index] : itemData, itemData: Array.isArray(itemData) ? itemData[index] : itemData,
}); });
returnData.push(...executionData); returnData = returnData.concat(executionData);
}); });
} }

View file

@ -11,6 +11,12 @@ import moment from 'moment-timezone';
import { notionApiRequest, simplifyObjects } from './shared/GenericFunctions'; import { notionApiRequest, simplifyObjects } from './shared/GenericFunctions';
import { listSearch } from './shared/methods'; import { listSearch } from './shared/methods';
import {
databaseUrlExtractionRegexp,
databaseUrlValidationRegexp,
idExtractionRegexp,
idValidationRegexp,
} from './shared/constants';
export class NotionTrigger implements INodeType { export class NotionTrigger implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
@ -85,16 +91,14 @@ export class NotionTrigger implements INodeType {
{ {
type: 'regex', type: 'regex',
properties: { properties: {
regex: regex: databaseUrlValidationRegexp,
'(?:https|http)://www.notion.so/(?:[a-z0-9-]{2,}/)?([0-9a-f]{8}[0-9a-f]{4}4[0-9a-f]{3}[89ab][0-9a-f]{3}[0-9a-f]{12}).*',
errorMessage: 'Not a valid Notion Database URL', errorMessage: 'Not a valid Notion Database URL',
}, },
}, },
], ],
extractValue: { extractValue: {
type: 'regex', type: 'regex',
regex: regex: databaseUrlExtractionRegexp,
'(?:https|http)://www.notion.so/(?:[a-z0-9-]{2,}/)?([0-9a-f]{8}[0-9a-f]{4}4[0-9a-f]{3}[89ab][0-9a-f]{3}[0-9a-f]{12})',
}, },
}, },
{ {
@ -106,15 +110,14 @@ export class NotionTrigger implements INodeType {
{ {
type: 'regex', type: 'regex',
properties: { properties: {
regex: regex: idValidationRegexp,
'^(([0-9a-f]{8}[0-9a-f]{4}4[0-9a-f]{3}[89ab][0-9a-f]{3}[0-9a-f]{12})|([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}))[ \t]*',
errorMessage: 'Not a valid Notion Database ID', errorMessage: 'Not a valid Notion Database ID',
}, },
}, },
], ],
extractValue: { extractValue: {
type: 'regex', type: 'regex',
regex: '^([0-9a-f]{8}-?[0-9a-f]{4}-?4[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12})', regex: idExtractionRegexp,
}, },
url: '=https://www.notion.so/{{$value.replace(/-/g, "")}}', url: '=https://www.notion.so/{{$value.replace(/-/g, "")}}',
}, },

View file

@ -23,6 +23,7 @@ import moment from 'moment-timezone';
import { validate as uuidValidate } from 'uuid'; import { validate as uuidValidate } from 'uuid';
import set from 'lodash/set'; import set from 'lodash/set';
import { filters } from './descriptions/Filters'; import { filters } from './descriptions/Filters';
import { blockUrlExtractionRegexp } from './constants';
function uuidValidateWithoutDashes(this: IExecuteFunctions, value: string) { function uuidValidateWithoutDashes(this: IExecuteFunctions, value: string) {
if (uuidValidate(value)) return true; if (uuidValidate(value)) return true;
@ -1152,8 +1153,7 @@ export function extractBlockId(this: IExecuteFunctions, nodeVersion: number, ite
const match = (blockIdRLCData.value as string).match(blockRegex); const match = (blockIdRLCData.value as string).match(blockRegex);
if (match === null) { if (match === null) {
const pageRegex = const pageRegex = new RegExp(blockUrlExtractionRegexp);
/(?:https|http):\/\/www\.notion\.so\/(?:[a-z0-9-]{2,}\/)?(?:[a-zA-Z0-9-]{2,}-)?([0-9a-f]{8}[0-9a-f]{4}4[0-9a-f]{3}[89ab][0-9a-f]{3}[0-9a-f]{12})/;
const pageMatch = (blockIdRLCData.value as string).match(pageRegex); const pageMatch = (blockIdRLCData.value as string).match(pageRegex);
if (pageMatch === null) { if (pageMatch === null) {

View file

@ -0,0 +1,15 @@
const notionIdRegexp = '[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}';
export const idExtractionRegexp = `^(${notionIdRegexp})`;
export const idValidationRegexp = `${idExtractionRegexp}.*`;
const baseUrlRegexp = '(?:https|http)://www\\.notion\\.so/(?:[a-z0-9-]{2,}/)?';
export const databaseUrlExtractionRegexp = `${baseUrlRegexp}(${notionIdRegexp})`;
export const databaseUrlValidationRegexp = `${databaseUrlExtractionRegexp}.*`;
export const databasePageUrlExtractionRegexp = `${baseUrlRegexp}(?:[a-zA-Z0-9-]{1,}-)?(${notionIdRegexp})`;
export const databasePageUrlValidationRegexp = `${databasePageUrlExtractionRegexp}.*`;
export const blockUrlExtractionRegexp = `${baseUrlRegexp}(?:[a-zA-Z0-9-]{2,}-)?(${notionIdRegexp})`;
export const blockUrlValidationRegexp = `${blockUrlExtractionRegexp}.*`;

View file

@ -1,6 +1,12 @@
import type { INodeProperties } from 'n8n-workflow'; import type { INodeProperties } from 'n8n-workflow';
import { blocks } from './Blocks'; import { blocks } from './Blocks';
import {
blockUrlExtractionRegexp,
blockUrlValidationRegexp,
idExtractionRegexp,
idValidationRegexp,
} from '../constants';
//RLC with fixed regex for blockId //RLC with fixed regex for blockId
const blockIdRLC: INodeProperties = { const blockIdRLC: INodeProperties = {
@ -20,15 +26,14 @@ const blockIdRLC: INodeProperties = {
{ {
type: 'regex', type: 'regex',
properties: { properties: {
regex: regex: blockUrlValidationRegexp,
'(?:https|http)://www.notion.so/(?:[a-z0-9-]{2,}/)?(?:[a-zA-Z0-9-]{2,}-)?([0-9a-f]{8}[0-9a-f]{4}4[0-9a-f]{3}[89ab][0-9a-f]{3}[0-9a-f]{12}).*',
errorMessage: 'Not a valid Notion Block URL', errorMessage: 'Not a valid Notion Block URL',
}, },
}, },
], ],
// extractValue: { // extractValue: {
// type: 'regex', // type: 'regex',
// regex: 'https:\\/\\/www\\.notion\\.so\\/.+\\?pvs=[0-9]+#([a-f0-9]{2,})', // regex: blockUrlExtractionRegexp,
// }, // },
}, },
{ {
@ -101,16 +106,14 @@ export const blockFields: INodeProperties[] = [
{ {
type: 'regex', type: 'regex',
properties: { properties: {
regex: regex: blockUrlValidationRegexp,
'(?:https|http)://www.notion.so/(?:[a-z0-9-]{2,}/)?(?:[a-zA-Z0-9-]{2,}-)?([0-9a-f]{8}[0-9a-f]{4}4[0-9a-f]{3}[89ab][0-9a-f]{3}[0-9a-f]{12}).*',
errorMessage: 'Not a valid Notion Block URL', errorMessage: 'Not a valid Notion Block URL',
}, },
}, },
], ],
extractValue: { extractValue: {
type: 'regex', type: 'regex',
regex: regex: blockUrlExtractionRegexp,
'(?:https|http)://www.notion.so/(?:[a-z0-9-]{2,}/)?(?:[a-zA-Z0-9-]{2,}-)?([0-9a-f]{8}[0-9a-f]{4}4[0-9a-f]{3}[89ab][0-9a-f]{3}[0-9a-f]{12})',
}, },
}, },
{ {
@ -122,15 +125,14 @@ export const blockFields: INodeProperties[] = [
{ {
type: 'regex', type: 'regex',
properties: { properties: {
regex: regex: idValidationRegexp,
'^(([0-9a-f]{8}[0-9a-f]{4}4[0-9a-f]{3}[89ab][0-9a-f]{3}[0-9a-f]{12})|([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}))[ \t]*',
errorMessage: 'Not a valid Notion Block ID', errorMessage: 'Not a valid Notion Block ID',
}, },
}, },
], ],
extractValue: { extractValue: {
type: 'regex', type: 'regex',
regex: '^([0-9a-f]{8}-?[0-9a-f]{4}-?4[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12})', regex: idExtractionRegexp,
}, },
url: '=https://www.notion.so/{{$value.replace(/-/g, "")}}', url: '=https://www.notion.so/{{$value.replace(/-/g, "")}}',
}, },
@ -176,16 +178,14 @@ export const blockFields: INodeProperties[] = [
{ {
type: 'regex', type: 'regex',
properties: { properties: {
regex: regex: blockUrlValidationRegexp,
'(?:https|http)://www.notion.so/(?:[a-z0-9-]{2,}/)?(?:[a-zA-Z0-9-]{2,}-)?([0-9a-f]{8}[0-9a-f]{4}4[0-9a-f]{3}[89ab][0-9a-f]{3}[0-9a-f]{12}).*',
errorMessage: 'Not a valid Notion Block URL', errorMessage: 'Not a valid Notion Block URL',
}, },
}, },
], ],
extractValue: { extractValue: {
type: 'regex', type: 'regex',
regex: regex: blockUrlExtractionRegexp,
'(?:https|http)://www.notion.so/(?:[a-z0-9-]{2,}/)?(?:[a-zA-Z0-9-]{2,}-)?([0-9a-f]{8}[0-9a-f]{4}4[0-9a-f]{3}[89ab][0-9a-f]{3}[0-9a-f]{12})',
}, },
}, },
{ {
@ -197,15 +197,14 @@ export const blockFields: INodeProperties[] = [
{ {
type: 'regex', type: 'regex',
properties: { properties: {
regex: regex: idValidationRegexp,
'^(([0-9a-f]{8}[0-9a-f]{4}4[0-9a-f]{3}[89ab][0-9a-f]{3}[0-9a-f]{12})|([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}))[ \t]*',
errorMessage: 'Not a valid Notion Block ID', errorMessage: 'Not a valid Notion Block ID',
}, },
}, },
], ],
extractValue: { extractValue: {
type: 'regex', type: 'regex',
regex: '^([0-9a-f]{8}-?[0-9a-f]{4}-?4[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12})', regex: idExtractionRegexp,
}, },
url: '=https://www.notion.so/{{$value.replace(/-/g, "")}}', url: '=https://www.notion.so/{{$value.replace(/-/g, "")}}',
}, },

View file

@ -1,4 +1,10 @@
import type { IDisplayOptions, INodeProperties } from 'n8n-workflow'; import type { IDisplayOptions, INodeProperties } from 'n8n-workflow';
import {
databaseUrlExtractionRegexp,
databaseUrlValidationRegexp,
idExtractionRegexp,
idValidationRegexp,
} from '../constants';
const colors = [ const colors = [
{ {
@ -221,16 +227,14 @@ const typeMention: INodeProperties[] = [
{ {
type: 'regex', type: 'regex',
properties: { properties: {
regex: regex: databaseUrlValidationRegexp,
'(?:https|http)://www.notion.so/(?:[a-z0-9-]{2,}/)?([0-9a-f]{8}[0-9a-f]{4}4[0-9a-f]{3}[89ab][0-9a-f]{3}[0-9a-f]{12}).*',
errorMessage: 'Not a valid Notion Database URL', errorMessage: 'Not a valid Notion Database URL',
}, },
}, },
], ],
extractValue: { extractValue: {
type: 'regex', type: 'regex',
regex: regex: databaseUrlExtractionRegexp,
'(?:https|http)://www.notion.so/(?:[a-z0-9-]{2,}/)?([0-9a-f]{8}[0-9a-f]{4}4[0-9a-f]{3}[89ab][0-9a-f]{3}[0-9a-f]{12})',
}, },
}, },
{ {
@ -242,15 +246,14 @@ const typeMention: INodeProperties[] = [
{ {
type: 'regex', type: 'regex',
properties: { properties: {
regex: regex: idValidationRegexp,
'^(([0-9a-f]{8}[0-9a-f]{4}4[0-9a-f]{3}[89ab][0-9a-f]{3}[0-9a-f]{12})|([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}))[ \t]*',
errorMessage: 'Not a valid Notion Database ID', errorMessage: 'Not a valid Notion Database ID',
}, },
}, },
], ],
extractValue: { extractValue: {
type: 'regex', type: 'regex',
regex: '^([0-9a-f]{8}-?[0-9a-f]{4}-?4[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12})', regex: idExtractionRegexp,
}, },
url: '=https://www.notion.so/{{$value.replace(/-/g, "")}}', url: '=https://www.notion.so/{{$value.replace(/-/g, "")}}',
}, },

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