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
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:
name: Merge back into master
needs: [publish-to-npm, create-github-release]
if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'release:patch') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.1.1
with:
fetch-depth: 0
- run: |
git checkout --track origin/master
git config user.name "github-actions[bot]"
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
git merge --ff n8n@${{ needs.publish-to-npm.outputs.release }}
git push origin master
git push origin :${{github.event.pull_request.base.ref}}
# merge-back-into-master:
# name: Merge back into master
# needs: [publish-to-npm, create-github-release]
# if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'release:patch') }}
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4.1.1
# with:
# fetch-depth: 0
# - run: |
# git checkout --track origin/master
# git config user.name "github-actions[bot]"
# git config user.email 41898282+github-actions[bot]@users.noreply.github.com
# git merge --ff n8n@${{ needs.publish-to-npm.outputs.release }}
# git push origin master
# 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)

View file

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "1.60.0",
"version": "1.61.0",
"private": true,
"engines": {
"node": ">=20.15",
@ -81,7 +81,7 @@
},
"patchedDependencies": {
"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",
"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",

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/config",
"version": "1.10.0",
"version": "1.11.0",
"scripts": {
"clean": "rimraf dist .turbo",
"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 { PublicApiConfig } from './configs/public-api.config';
import { ScalingModeConfig } from './configs/scaling-mode.config';
import { SentryConfig } from './configs/sentry.config';
import { TemplatesConfig } from './configs/templates.config';
import { UserManagementConfig } from './configs/user-management.config';
import { VersionNotificationsConfig } from './configs/version-notifications.config';
@ -49,6 +50,9 @@ export class GlobalConfig {
@Nested
workflows: WorkflowsConfig;
@Nested
sentry: SentryConfig;
/** Path n8n is deployed to */
@Env('N8N_PATH')
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', () => {

View file

@ -85,7 +85,6 @@ function getInputs(
'@n8n/n8n-nodes-langchain.lmChatGroq',
'@n8n/n8n-nodes-langchain.lmChatOllama',
'@n8n/n8n-nodes-langchain.lmChatOpenAi',
'@n8n/n8n-nodes-langchain.lmChatGooglePalm',
'@n8n/n8n-nodes-langchain.lmChatGoogleGemini',
'@n8n/n8n-nodes-langchain.lmChatGoogleVertex',
'@n8n/n8n-nodes-langchain.lmChatMistralCloud',
@ -111,11 +110,13 @@ function getInputs(
nodes: [
'@n8n/n8n-nodes-langchain.lmChatAnthropic',
'@n8n/n8n-nodes-langchain.lmChatAzureOpenAi',
'@n8n/n8n-nodes-langchain.lmChatAwsBedrock',
'@n8n/n8n-nodes-langchain.lmChatMistralCloud',
'@n8n/n8n-nodes-langchain.lmChatOllama',
'@n8n/n8n-nodes-langchain.lmChatOpenAi',
'@n8n/n8n-nodes-langchain.lmChatGroq',
'@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 */
import { BedrockEmbeddings } from '@langchain/aws';
import {
NodeConnectionType,
type IExecuteFunctions,
@ -6,7 +7,6 @@ import {
type INodeTypeDescription,
type SupplyData,
} from 'n8n-workflow';
import { BedrockEmbeddings } from '@langchain/community/embeddings/bedrock';
import { logWrapper } from '../../../utils/logWrapper';
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 */
import { ChatBedrockConverse } from '@langchain/aws';
import {
NodeConnectionType,
type IExecuteFunctions,
@ -6,13 +7,8 @@ import {
type INodeTypeDescription,
type SupplyData,
} from 'n8n-workflow';
import { BedrockChat } from '@langchain/community/chat_models/bedrock';
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';
export class LmChatAwsBedrock implements INodeType {
@ -144,7 +140,7 @@ export class LmChatAwsBedrock implements INodeType {
maxTokensToSample: number;
};
const model = new BedrockChat({
const model = new ChatBedrockConverse({
region: credentials.region as string,
model: modelName,
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",
"version": "1.60.0",
"version": "1.61.0",
"description": "",
"main": "index.js",
"scripts": {
@ -57,7 +57,6 @@
"dist/nodes/embeddings/EmbeddingsCohere/EmbeddingsCohere.node.js",
"dist/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.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/EmbeddingsHuggingFaceInference/EmbeddingsHuggingFaceInference.node.js",
"dist/nodes/embeddings/EmbeddingsMistralCloud/EmbeddingsMistralCloud.node.js",
@ -65,9 +64,7 @@
"dist/nodes/embeddings/EmbeddingsOllama/EmbeddingsOllama.node.js",
"dist/nodes/llms/LMChatAnthropic/LmChatAnthropic.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/LmChatGooglePalm/LmChatGooglePalm.node.js",
"dist/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.js",
"dist/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.js",
"dist/nodes/llms/LmChatGroq/LmChatGroq.node.js",
@ -131,40 +128,38 @@
"n8n-core": "workspace:*"
},
"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-js": "0.9.0",
"@google-ai/generativelanguage": "2.5.0",
"@google-ai/generativelanguage": "2.6.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",
"@langchain/anthropic": "0.2.16",
"@langchain/cohere": "0.2.2",
"@langchain/community": "0.2.32",
"@langchain/anthropic": "0.3.1",
"@langchain/aws": "^0.1.0",
"@langchain/cohere": "0.3.0",
"@langchain/community": "0.3.2",
"@langchain/core": "catalog:",
"@langchain/google-genai": "0.0.26",
"@langchain/google-vertexai": "0.0.27",
"@langchain/groq": "0.0.17",
"@langchain/mistralai": "0.0.29",
"@langchain/ollama": "0.0.4",
"@langchain/openai": "0.2.10",
"@langchain/pinecone": "0.0.9",
"@langchain/qdrant": "0.0.5",
"@langchain/redis": "0.0.5",
"@langchain/textsplitters": "0.0.3",
"@langchain/google-genai": "0.1.0",
"@langchain/google-vertexai": "0.1.0",
"@langchain/groq": "0.1.2",
"@langchain/mistralai": "0.1.1",
"@langchain/ollama": "0.1.0",
"@langchain/openai": "0.3.0",
"@langchain/pinecone": "0.1.0",
"@langchain/qdrant": "0.1.0",
"@langchain/redis": "0.1.0",
"@langchain/textsplitters": "0.1.0",
"@mozilla/readability": "^0.5.0",
"@n8n/typeorm": "0.3.20-10",
"@n8n/vm2": "3.9.25",
"@pinecone-database/pinecone": "3.0.0",
"@qdrant/js-client-rest": "1.9.0",
"@supabase/supabase-js": "2.45.3",
"@pinecone-database/pinecone": "3.0.3",
"@qdrant/js-client-rest": "1.11.0",
"@supabase/supabase-js": "2.45.4",
"@types/pg": "^8.11.6",
"@xata.io/client": "0.28.4",
"@xata.io/client": "0.30.0",
"basic-auth": "catalog:",
"cheerio": "1.0.0-rc.12",
"cohere-ai": "7.13.0",
"cohere-ai": "7.13.2",
"d3-dsv": "2.0.0",
"epub2": "3.0.2",
"form-data": "catalog:",
@ -172,12 +167,12 @@
"html-to-text": "9.0.5",
"jsdom": "^23.0.1",
"json-schema-to-zod": "2.1.0",
"langchain": "0.2.18",
"langchain": "0.3.2",
"lodash": "catalog:",
"mammoth": "1.7.2",
"n8n-nodes-base": "workspace:*",
"n8n-workflow": "workspace:*",
"openai": "4.58.0",
"openai": "4.63.0",
"pdf-parse": "1.1.1",
"pg": "8.12.0",
"redis": "4.6.12",
@ -185,6 +180,6 @@
"temp": "0.9.4",
"tmp-promise": "3.0.3",
"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('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') {
require('dns').setDefaultResultOrder('ipv4first');
}

View file

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

View file

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

View file

@ -452,14 +452,6 @@ export const schema = {
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: {
doc: 'Diagnostics config for frontend.',
format: String,

View file

@ -34,7 +34,7 @@ import { ExternalHooks } from '@/external-hooks';
import { validateEntity } from '@/generic-helpers';
import type { ICredentialsDb } from '@/interfaces';
import { Logger } from '@/logger';
import { userHasScope } from '@/permissions/check-access';
import { userHasScopes } from '@/permissions/check-access';
import type { CredentialRequest, ListQuery } from '@/requests';
import { CredentialsTester } from '@/services/credentials-tester.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
// the cases we need it for.
if (
!(await userHasScope(user, ['credential:update'], false, { credentialId: credential.id }))
!(await userHasScopes(user, ['credential:update'], false, { credentialId: credential.id }))
) {
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 type { BooleanLicenseFeature } from '@/interfaces';
import { License } from '@/license';
import { userHasScope } from '@/permissions/check-access';
import { userHasScopes } from '@/permissions/check-access';
import type { AuthenticatedRequest } from '@/requests';
import { send } from '@/response-helper'; // TODO: move `ResponseHelper.send` to this file
@ -151,7 +151,7 @@ export class ControllerRegistry {
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({
status: 'error',
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
import { QueryFailedError } from '@n8n/typeorm';
import { createHash } from 'crypto';
import { ErrorReporterProxy, ApplicationError } from 'n8n-workflow';
import config from '@/config';
import Container from 'typedi';
let initialized = false;
@ -14,7 +14,7 @@ export const initErrorHandling = async () => {
ErrorReporterProxy.error(error);
});
const dsn = config.getEnv('diagnostics.config.sentry.dsn');
const dsn = Container.get(GlobalConfig).sentry.backendDsn;
if (!dsn) {
initialized = true;
return;
@ -29,7 +29,7 @@ export const initErrorHandling = async () => {
DEPLOYMENT_NAME: serverName,
} = process.env;
const { init, captureException, addEventProcessor } = await import('@sentry/node');
const { init, captureException } = await import('@sentry/node');
const { RewriteFrames } = await import('@sentry/integrations');
const { Integrations } = await import('@sentry/node');
@ -41,6 +41,8 @@ export const initErrorHandling = async () => {
'OnUnhandledRejection',
'ContextLines',
];
const seenErrors = new Set<string>();
init({
dsn,
release,
@ -62,34 +64,32 @@ export const initErrorHandling = async () => {
},
}),
],
});
beforeSend(event, { originalException }) {
if (!originalException) return null;
const seenErrors = new Set<string>();
addEventProcessor((event, { originalException }) => {
if (!originalException) return null;
if (
originalException instanceof QueryFailedError &&
['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => originalException.message.includes(errMsg))
) {
return null;
}
if (
originalException instanceof QueryFailedError &&
['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => originalException.message.includes(errMsg))
) {
return null;
}
if (originalException instanceof ApplicationError) {
const { level, extra, tags } = originalException;
if (level === 'warning') return null;
event.level = level;
if (extra) event.extra = { ...event.extra, ...extra };
if (tags) event.tags = { ...event.tags, ...tags };
}
if (originalException instanceof ApplicationError) {
const { level, extra, tags } = originalException;
if (level === 'warning') return null;
event.level = level;
if (extra) event.extra = { ...event.extra, ...extra };
if (tags) event.tags = { ...event.tags, ...tags };
}
if (originalException instanceof Error && originalException.stack) {
const eventHash = createHash('sha1').update(originalException.stack).digest('base64');
if (seenErrors.has(eventHash)) return null;
seenErrors.add(eventHash);
}
if (originalException instanceof Error && originalException.stack) {
const eventHash = createHash('sha1').update(originalException.stack).digest('base64');
if (seenErrors.has(eventHash)) return null;
seenErrors.add(eventHash);
}
return event;
return event;
},
});
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 { 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,
scopes: Scope[],
globalOnly: boolean,
@ -18,15 +26,14 @@ export const userHasScope = async (
credentialId,
workflowId,
projectId,
}: { credentialId?: string; workflowId?: string; projectId?: string },
): Promise<boolean> => {
// Short circuit here since a global role will always have access
if (user.hasGlobalScope(scopes, { mode: 'allOf' })) {
return true;
} else if (globalOnly) {
// The above check already failed so the user doesn't have access
return false;
}
}: { credentialId?: string; workflowId?: string; projectId?: string } /* only one */,
): Promise<boolean> {
if (user.hasGlobalScope(scopes, { mode: 'allOf' })) return true;
if (globalOnly) return false;
// Find which project roles are defined to contain the required scopes.
// Then find projects having this user and having those project roles.
const roleService = Container.get(RoleService);
const projectRoles = roleService.rolesWithScope('project', scopes);
@ -42,47 +49,29 @@ export const userHasScope = async (
})
).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) {
const exists = await Container.get(SharedCredentialsRepository).find({
where: {
projectId: In(userProjectIds),
credentialsId: credentialId,
role: In(roleService.rolesWithScope('credential', scopes)),
},
return await Container.get(SharedCredentialsRepository).existsBy({
credentialsId: credentialId,
projectId: In(userProjectIds),
role: In(roleService.rolesWithScope('credential', scopes)),
});
if (!exists.length) {
return false;
}
return true;
}
if (workflowId) {
const exists = await Container.get(SharedWorkflowRepository).find({
where: {
projectId: In(userProjectIds),
workflowId,
role: In(roleService.rolesWithScope('workflow', scopes)),
},
return await Container.get(SharedWorkflowRepository).existsBy({
workflowId,
projectId: In(userProjectIds),
role: In(roleService.rolesWithScope('workflow', scopes)),
});
if (!exists.length) {
return false;
}
return true;
}
if (projectId) {
if (!userProjectIds.includes(projectId)) {
return false;
}
return true;
}
if (projectId) return userProjectIds.includes(projectId);
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 type { BooleanLicenseFeature } from '@/interfaces';
import { License } from '@/license';
import { userHasScope } from '@/permissions/check-access';
import { userHasScopes } from '@/permissions/check-access';
import type { AuthenticatedRequest } from '@/requests';
import type { PaginatedRequest } from '../../../types';
@ -34,7 +34,7 @@ const buildScopeMiddleware = (
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' });
}

View file

@ -49,6 +49,9 @@ export type AuthenticatedRequest<
> = Omit<APIRequest<RouteParams, ResponseBody, RequestBody, RequestQuery>, 'user' | 'cookies'> & {
user: User;
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 express from 'express';
import { access as fsAccess } from 'fs/promises';
@ -21,6 +20,7 @@ import {
import { CredentialsOverwrites } from '@/credentials-overwrites';
import { ControllerRegistry } from '@/decorators';
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 type { ICredentialsOverwrite } from '@/interfaces';
import { isLdapEnabled } from '@/ldap/helpers.ee';
@ -58,12 +58,12 @@ import '@/controllers/user-settings.controller';
import '@/controllers/workflow-statistics.controller';
import '@/credentials/credentials.controller';
import '@/eventbus/event-bus.controller';
import '@/events/events.controller';
import '@/executions/executions.controller';
import '@/external-secrets/external-secrets.controller.ee';
import '@/license/license.controller';
import '@/workflows/workflow-history/workflow-history.controller.ee';
import '@/workflows/workflows.controller';
import { EventService } from './events/event.service';
@Service()
export class Server extends AbstractServer {
@ -169,10 +169,6 @@ export class Server extends AbstractServer {
const { frontendService } = this;
if (frontendService) {
frontendService.addToSettings({
versionCli: N8N_VERSION,
});
await this.externalHooks.run('frontend.settings', [frontendService.getSettings()]);
}
@ -244,11 +240,22 @@ export class Server extends AbstractServer {
// Returns the current settings for the UI
this.app.get(
`/${this.restEndpoint}/settings`,
ResponseHelper.send(
async (req: express.Request): Promise<FrontendSettings> =>
frontendService.getSettings(req.headers['push-ref'] as string),
),
ResponseHelper.send(async () => frontendService.getSettings()),
);
// 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 config from '@/config';
import { LICENSE_FEATURES } from '@/constants';
import { LICENSE_FEATURES, N8N_VERSION } from '@/constants';
import { CredentialTypes } from '@/credential-types';
import { CredentialsOverwrites } from '@/credentials-overwrites';
import { getVariablesLimit } from '@/environments/variables/environment-helpers';
import { EventService } from '@/events/event.service';
import { getLdapLoginLabel } from '@/ldap/helpers.ee';
import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
@ -47,7 +46,6 @@ export class FrontendService {
private readonly mailer: UserManagementMailer,
private readonly instanceSettings: InstanceSettings,
private readonly urlService: UrlService,
private readonly eventService: EventService,
) {
loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes());
void this.generateTypes();
@ -102,7 +100,7 @@ export class FrontendService {
urlBaseEditor: instanceBaseUrl,
binaryDataMode: config.getEnv('binaryDataManager.mode'),
nodeJsVersion: process.version.replace(/^v/, ''),
versionCli: '',
versionCli: N8N_VERSION,
concurrency: config.getEnv('executions.concurrency.productionLimit'),
authCookie: {
secure: config.getEnv('secure_cookie'),
@ -242,9 +240,7 @@ export class FrontendService {
this.writeStaticJSON('credentials', credentials);
}
getSettings(pushRef?: string): FrontendSettings {
this.eventService.emit('session-started', { pushRef });
getSettings(): FrontendSettings {
const restEndpoint = this.globalConfig.endpoints.rest;
// Update all urls, in case `WEBHOOK_URL` was updated by `--tunnel`
@ -344,10 +340,6 @@ export class FrontendService {
return this.settings;
}
addToSettings(newSettings: Record<string, unknown>) {
this.settings = { ...this.settings, ...newSettings };
}
private writeStaticJSON(name: string, data: INodeTypeBaseDescription[] | ICredentialType[]) {
const { staticCacheDir } = this.instanceSettings;
const filePath = path.join(staticCacheDir, `types/${name}.json`);

View file

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

View file

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

View file

@ -125,6 +125,32 @@ export class DirectedGraph {
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) {
const nodeExists = this.nodes.get(node.name) === node;
a.ok(nodeExists);

View file

@ -38,4 +38,52 @@ describe('DirectedGraph', () => {
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,
findTriggerForPartialExecution,
} from './PartialExecutionUtils';
import { cleanRunData } from './PartialExecutionUtils/cleanRunData';
export class WorkflowExecute {
private status: ExecutionStatus = 'new';
@ -347,7 +348,8 @@ export class WorkflowExecute {
}
// 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();
// 3. Find the Start Nodes
@ -362,7 +364,7 @@ export class WorkflowExecute {
}
// 6. Clean Run Data
// TODO:
const newRunData: IRunData = cleanRunData(runData, graph, startNodes);
// 7. Recreate Execution Stack
const { nodeExecutionStack, waitingExecution, waitingExecutionSource } =
@ -376,7 +378,7 @@ export class WorkflowExecute {
runNodeFilter: Array.from(filteredNodes.values()).map((node) => node.name),
},
resultData: {
runData,
runData: newRunData,
pinData,
},
executionData: {

View file

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

View file

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

View file

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

View file

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

View file

@ -9,6 +9,7 @@
window.BASE_PATH = '/{{BASE_PATH}}/';
window.REST_ENDPOINT = '{{REST_ENDPOINT}}';
</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>
<title>n8n.io - Workflow Automation</title>

View file

@ -1,6 +1,6 @@
{
"name": "n8n-editor-ui",
"version": "1.60.0",
"version": "1.61.0",
"description": "Workflow Editor UI for n8n",
"main": "index.js",
"scripts": {
@ -38,6 +38,7 @@
"@n8n/codemirror-lang": "workspace:*",
"@n8n/codemirror-lang-sql": "^1.0.2",
"@n8n/permissions": "workspace:*",
"@sentry/vue": "^8.31.0",
"@vue-flow/background": "^1.3.0",
"@vue-flow/controls": "^1.1.1",
"@vue-flow/core": "^1.33.5",
@ -83,7 +84,7 @@
"@faker-js/faker": "^8.0.2",
"@iconify/json": "^2.2.228",
"@pinia/testing": "^0.1.3",
"@sentry/vite-plugin": "^2.5.0",
"@sentry/vite-plugin": "^2.22.4",
"@types/dateformat": "^3.0.0",
"@types/file-saver": "^2.0.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 jsParser from 'prettier/plugins/babel';
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 { codeNodeEditorEventBus } from '@/event-bus';
@ -26,6 +26,7 @@ import { useLinter } from './linter';
import { codeNodeEditorTheme } from './theme';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { dropInCodeEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
type Props = {
mode: CodeExecutionMode;
@ -51,6 +52,7 @@ const emit = defineEmits<{
const message = useMessage();
const editor = ref(null) as Ref<EditorView | null>;
const languageCompartment = ref(new Compartment());
const dragAndDropCompartment = ref(new Compartment());
const linterCompartment = ref(new Compartment());
const isEditorHovered = ref(false);
const isEditorFocused = ref(false);
@ -95,6 +97,7 @@ onMounted(() => {
extensions.push(
...writableEditorExtensions,
dragAndDropCompartment.value.of(dragAndDropExtension.value),
EditorView.domEventHandlers({
focus: () => {
isEditorFocused.value = true;
@ -151,6 +154,12 @@ const placeholder = computed(() => {
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
const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => {
switch (props.language) {
@ -188,6 +197,12 @@ watch(
},
);
watch(dragAndDropExtension, (extension) => {
editor.value?.dispatch({
effects: dragAndDropCompartment.value.reconfigure(extension),
});
});
watch(
() => props.language,
(_newLanguage, previousLanguage: CodeNodeEditorLanguage) => {
@ -202,7 +217,6 @@ watch(
reloadLinter();
},
);
watch(
aiEnabled,
async (isEnabled) => {
@ -361,6 +375,12 @@ function onAiLoadStart() {
function onAiLoadEnd() {
isLoadingAIResponse.value = false;
}
async function onDrop(value: string, event: MouseEvent) {
if (!editor.value) return;
await dropInCodeEditor(toRaw(editor.value), event, value);
}
</script>
<template>
@ -384,10 +404,20 @@ function onAiLoadEnd() {
data-test-id="code-node-tab-code"
:class="$style.fillHeight"
>
<div
ref="codeNodeEditorRef"
:class="['ph-no-capture', 'code-editor-tabs', $style.editorInput, $style.fillHeight]"
/>
<DraggableTarget type="mapping" :disabled="!dragAndDropEnabled" @drop="onDrop">
<template #default="{ activeDrop, droppable }">
<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" />
</el-tab-pane>
<el-tab-pane
@ -407,7 +437,19 @@ function onAiLoadEnd() {
</el-tabs>
<!-- If AskAi not enabled, there's no point in rendering tabs -->
<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" />
</div>
</div>
@ -415,7 +457,7 @@ function onAiLoadEnd() {
<style scoped lang="scss">
:deep(.el-tabs) {
.code-editor-tabs .cm-editor {
.cm-editor {
border: 0;
}
}
@ -454,4 +496,21 @@ function onAiLoadEnd() {
.fillHeight {
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>

View file

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

View file

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

View file

@ -10,7 +10,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
import { useTelemetry } from '@/composables/useTelemetry';
import { dropInEditor } from '@/plugins/codemirror/dragAndDrop';
import { dropInExpressionEditor } from '@/plugins/codemirror/dragAndDrop';
import type { Segment } from '@/types/expressions';
import { startCompletion } from '@codemirror/autocomplete';
import type { EditorState, SelectionRange } from '@codemirror/state';
@ -119,7 +119,9 @@ async function onDrop(value: string, event: MouseEvent) {
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) {
setCursorPosition((droppedSelection.ranges.at(0)?.head ?? 3) - 3);

View file

@ -90,8 +90,8 @@ const allIssues = computed(() => {
const now = computed(() => DateTime.now().toISO());
const leftParameter = computed<INodeProperties>(() => ({
name: '',
displayName: '',
name: 'left',
displayName: 'Left',
default: '',
placeholder:
operator.value.type === 'dateTime'
@ -103,8 +103,8 @@ const leftParameter = computed<INodeProperties>(() => ({
const rightParameter = computed<INodeProperties>(() => {
const type = operator.value.rightType ?? operator.value.type;
return {
name: '',
displayName: '',
name: 'right',
displayName: 'Right',
default: '',
placeholder:
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 htmlParser from 'prettier/plugins/html';
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 { useExpressionEditor } from '@/composables/useExpressionEditor';
@ -37,6 +37,7 @@ import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import type { Range, Section } from './types';
import { nonTakenRanges } from './utils';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
type Props = {
modelValue: string;
@ -84,6 +85,7 @@ const extensions = computed(() => [
dropCursor(),
indentOnInput(),
highlightActiveLine(),
mappingDropCursor(),
]);
const {
editor: editorRef,
@ -238,11 +240,25 @@ onMounted(() => {
onBeforeUnmount(() => {
htmlEditorEventBus.off('format-html', formatHtml);
});
async function onDrop(value: string, event: MouseEvent) {
if (!editorRef.value) return;
await dropInExpressionEditor(toRaw(editorRef.value), event, value);
}
</script>
<template>
<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" />
</div>
</template>
@ -255,4 +271,21 @@ onBeforeUnmount(() => {
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>

View file

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

View file

@ -27,6 +27,7 @@ describe('InlineExpressionTip.vue', () => {
mockNdvState = {
hasInputData: true,
isNDVDataEmpty: vi.fn(() => true),
setHighlightDraggables: vi.fn(),
};
});
@ -43,11 +44,16 @@ describe('InlineExpressionTip.vue', () => {
hasInputData: true,
isNDVDataEmpty: vi.fn(() => false),
focusedMappableInput: 'Some Input',
setHighlightDraggables: vi.fn(),
};
const { container } = renderComponent(InlineExpressionTip, {
const { container, unmount } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(),
});
expect(mockNdvState.setHighlightDraggables).toHaveBeenCalledWith(true);
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,
isNDVDataEmpty: vi.fn(() => false),
focusedMappableInput: 'Some Input',
setHighlightDraggables: vi.fn(),
};
const { container } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(),

View file

@ -24,6 +24,7 @@ import {
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { computed, onMounted, ref, watch } from 'vue';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
type Props = {
modelValue: string;
@ -69,6 +70,7 @@ const extensions = computed(() => {
foldGutter(),
dropCursor(),
bracketMatching(),
mappingDropCursor(),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (!viewUpdate.docChanged || !editor.value) return;
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 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) {
return remoteParameterOptionsKeys.value.includes(option.name);
}
@ -965,7 +987,11 @@ onUpdated(async () => {
</script>
<template>
<div ref="wrapper" :class="parameterInputClasses" @keydown.stop>
<div
ref="wrapper"
:class="[parameterInputClasses, { [$style.tipVisible]: showDragnDropTip }]"
@keydown.stop
>
<ExpressionEditModal
:dialog-visible="expressionEditDialogVisible"
:model-value="modelValueExpressionEdit"
@ -1447,6 +1473,9 @@ onUpdated(async () => {
:disabled="isReadOnly"
@update:model-value="valueChanged"
/>
<div v-if="showDragnDropTip" :class="$style.tip">
<InlineExpressionTip />
</div>
</div>
<ParameterIssues
@ -1477,6 +1506,7 @@ onUpdated(async () => {
.parameter-input {
display: inline-block;
position: relative;
:deep(.color-input) {
display: flex;
@ -1609,3 +1639,23 @@ onUpdated(async () => {
}
}
</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 { createEventBus } from 'n8n-design-system/utils';
import type { INodeProperties, IParameterLabel, NodeParameterValueType } from 'n8n-workflow';
import InlineExpressionTip from './InlineExpressionEditor/InlineExpressionTip.vue';
type Props = {
parameter: INodeProperties;
@ -57,8 +56,7 @@ const ndvStore = useNDVStore();
const node = computed(() => ndvStore.activeNode);
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(
() => props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector',
);
@ -73,17 +71,6 @@ const isExpression = computed(() => isValueExpression(props.parameter, props.val
const showExpressionSelector = computed(() =>
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() {
focused.value = true;
@ -205,7 +192,7 @@ function onDrop(newParamValue: string) {
<template>
<n8n-input-label
:class="[$style.wrapper, { [$style.tipVisible]: showDragnDropTip }]"
:class="[$style.wrapper]"
:label="hideLabel ? '' : i18n.nodeText().inputLabelDisplayName(parameter, path)"
:tooltip-text="hideLabel ? '' : i18n.nodeText().inputLabelDescription(parameter, path)"
:show-tooltip="focused"
@ -258,9 +245,6 @@ function onDrop(newParamValue: string) {
/>
</template>
</DraggableTarget>
<div v-if="showDragnDropTip" :class="$style.tip">
<InlineExpressionTip />
</div>
<div
:class="{
[$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 {
position: absolute;
bottom: -22px;

View file

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

View file

@ -42,6 +42,7 @@ type SchemaNode = {
depth: number;
loading: boolean;
open: boolean;
connectedOutputIndexes: number[];
itemsCount: number | null;
schema: Schema | null;
};
@ -94,6 +95,7 @@ const nodes = computed(() => {
return {
node: fullNode,
connectedOutputIndexes: node.indicies,
depth: node.depth,
itemsCount,
nodeType,
@ -141,19 +143,17 @@ const highlight = computed(() => ndvStore.highlightDraggables);
const allNodesOpen = 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 data =
pinData ??
executionDataToJson(
getNodeInputData(
node,
props.runIndex,
props.outputIndex,
props.paneType,
props.connectionType,
) ?? [],
);
connectedOutputIndexes
.map((outputIndex) =>
executionDataToJson(
getNodeInputData(node, props.runIndex, outputIndex, props.paneType, props.connectionType),
),
)
.flat();
nodesData.value[node.name] = {
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;
if (open) {
nodesOpen.value[node.name] = false;
@ -170,7 +171,7 @@ const toggleOpenNode = async ({ node, schema, open }: SchemaNode, exclusive = fa
if (!schema) {
nodesLoading.value[node.name] = true;
await loadNodeData(node);
await loadNodeData(schemaNode);
nodesLoading.value[node.name] = false;
}
@ -182,8 +183,8 @@ const toggleOpenNode = async ({ node, schema, open }: SchemaNode, exclusive = fa
};
const openAllNodes = async () => {
const nodesToLoad = nodes.value.filter((node) => !node.schema).map(({ node }) => node);
await Promise.all(nodesToLoad.map(async (node) => await loadNodeData(node)));
const nodesToLoad = nodes.value.filter((node) => !node.schema);
await Promise.all(nodesToLoad.map(loadNodeData));
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 v-if="files.length > 0">
<div v-if="workflowFiles.length > 0">
<n8n-text>
<n8n-text tag="div" class="mb-l">
{{ i18n.baseText('settings.sourceControl.modals.push.description') }}
<n8n-link :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
</n8n-link>
</n8n-text>
<div class="mt-l mb-2xs">
<n8n-checkbox
:indeterminate="selectAllIndeterminate"
:model-value="selectAll"
@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])"
<n8n-checkbox
:class="$style.selectAll"
:indeterminate="selectAllIndeterminate"
:model-value="selectAll"
data-test-id="source-control-push-modal-toggle-all"
@update:model-value="onToggleSelectAll"
>
<div :class="$style.listItemBody">
<n8n-checkbox
:model-value="staged[file.file]"
:class="$style.listItemCheckbox"
@update:model-value="setStagedStatus(file, !staged[file.file])"
/>
<div>
<n8n-text v-if="file.status === 'deleted'" color="text-light">
<span v-if="file.type === 'workflow'"> Deleted Workflow: </span>
<span v-if="file.type === 'credential'"> Deleted Credential: </span>
<strong>{{ file.name || file.id }}</strong>
</n8n-text>
<n8n-text v-else bold> {{ file.name }} </n8n-text>
<div v-if="file.updatedAt">
<n8n-text color="text-light" size="small">
{{ renderUpdatedAt(file) }}
</n8n-text>
</div>
</div>
<div :class="$style.listItemStatus">
<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>
</div>
</div>
</n8n-card>
<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>
<n8n-checkbox
v-for="file in sortedFiles"
:key="file.file"
:class="[
'scopedListItem',
$style.listItem,
{ [$style.hiddenListItem]: defaultStagedFileTypes.includes(file.type) },
]"
data-test-id="source-control-push-modal-file-checkbox"
:model-value="staged[file.file]"
@update:model-value="setStagedStatus(file, !staged[file.file])"
>
<span>
<n8n-text v-if="file.status === 'deleted'" color="text-light">
<span v-if="file.type === 'workflow'"> Deleted Workflow: </span>
<span v-if="file.type === 'credential'"> Deleted Credential: </span>
<strong>{{ file.name || file.id }}</strong>
</n8n-text>
<n8n-text v-else bold> {{ file.name }} </n8n-text>
<n8n-text
v-if="file.updatedAt"
tag="p"
class="mt-0"
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>
<n8n-notice v-else class="mt-0">
<i18n-t keypath="settings.sourceControl.modals.push.noWorkflowChanges">
@ -380,11 +380,15 @@ function getStatusText(file: SourceControlAggregatedFile): string {
}
.listItem {
margin-top: var(--spacing-2xs);
margin-bottom: var(--spacing-2xs);
display: flex;
width: 100%;
align-items: center;
margin: var(--spacing-2xs) 0 var(--spacing-2xs);
padding: var(--spacing-xs);
cursor: pointer;
transition: border 0.3s ease;
padding: var(--spacing-xs);
border-radius: var(--border-radius-large);
border: var(--border-base);
&:hover {
border-color: var(--color-foreground-dark);
@ -397,22 +401,16 @@ function getStatusText(file: SourceControlAggregatedFile): string {
&:last-child {
margin-bottom: 0;
}
&.hiddenListItem {
display: none !important;
}
}
.listItemBody {
display: flex;
flex-direction: row;
align-items: center;
}
.listItemCheckbox {
display: inline-flex !important;
margin-bottom: 0 !important;
margin-right: var(--spacing-2xs) !important;
}
.listItemStatus {
margin-left: auto;
.selectAll {
float: left;
clear: both;
margin: 0 0 var(--spacing-2xs);
}
.footer {
@ -421,3 +419,12 @@ function getStatusText(file: SourceControlAggregatedFile): string {
justify-content: flex-end;
}
</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,
keywordCompletionSource,
} 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 { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
const SQL_DIALECTS = {
StandardSQL,
@ -111,6 +112,7 @@ const extensions = computed(() => {
foldGutter(),
dropCursor(),
bracketMatching(),
mappingDropCursor(),
]);
}
return baseExtensions;
@ -178,11 +180,28 @@ function highlightLine(lineNumber: number | 'final') {
selection: { anchor: lineToHighlight.from },
});
}
async function onDrop(value: string, event: MouseEvent) {
if (!editor.value) return;
await dropInExpressionEditor(toRaw(editor.value), event, value);
}
</script>
<template>
<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" />
<InlineExpressionEditorOutput
v-if="!fullscreen"
@ -202,4 +221,21 @@ function highlightLine(lineNumber: number | 'final') {
.codemirror {
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>

View file

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

View file

@ -2,13 +2,19 @@ import { createComponentRenderer } from '@/__tests__/render';
import RunDataJsonSchema from '@/components/RunDataSchema.vue';
import { useWorkflowsStore } from '@/stores/workflows.store';
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 { createTestNode, defaultNodeDescriptions } from '@/__tests__/mocks';
import { SET_NODE_TYPE } from '@/constants';
import {
createTestNode,
defaultNodeDescriptions,
mockNodeTypeDescription,
} from '@/__tests__/mocks';
import { IF_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { mock } from 'vitest-mock-extended';
import type { IWorkflowDb } from '@/Interface';
import { NodeConnectionType, type IDataObject } from 'n8n-workflow';
import * as nodeHelpers from '@/composables/useNodeHelpers';
const mockNode1 = createTestNode({
name: 'Set1',
@ -31,13 +37,20 @@ const disabledNode = createTestNode({
disabled: true,
});
const ifNode = createTestNode({
name: 'If',
type: IF_NODE_TYPE,
typeVersion: 1,
disabled: false,
});
async function setupStore() {
const workflow = mock<IWorkflowDb>({
id: '123',
name: 'Test Workflow',
connections: {},
active: true,
nodes: [mockNode1, mockNode2, disabledNode],
nodes: [mockNode1, mockNode2, disabledNode, ifNode],
});
const pinia = createPinia();
@ -46,12 +59,33 @@ async function setupStore() {
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
nodeTypesStore.setNodeTypes([
...defaultNodeDescriptions,
mockNodeTypeDescription({
name: IF_NODE_TYPE,
outputs: [NodeConnectionType.Main, NodeConnectionType.Main],
}),
]);
workflowsStore.workflow = workflow;
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', () => {
let renderComponent: ReturnType<typeof createComponentRenderer>;
@ -122,7 +156,7 @@ describe('RunDataSchema.vue', () => {
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({
props: {
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: [] }]]])(
'renders schema instead of showing no data for %o',
(data) => {

View file

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

View file

@ -4,7 +4,7 @@ import { NodeConnectionType } from 'n8n-workflow';
import { createCanvasNodeProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { CanvasNodeRenderType } from '@/types';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
const renderComponent = createComponentRenderer(CanvasNodeDefault);
@ -158,6 +158,36 @@ describe('CanvasNodeDefault', () => {
});
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', () => {

View file

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

View file

@ -1,36 +1,12 @@
import CanvasNodeDisabledStrikeThrough from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeDisabledStrikeThrough.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { NodeConnectionType } from 'n8n-workflow';
import { createCanvasNodeProvide } from '@/__tests__/data';
import { CanvasConnectionMode } from '@/types';
const renderComponent = createComponentRenderer(CanvasNodeDisabledStrikeThrough);
describe('CanvasNodeDisabledStrikeThrough', () => {
it('should render node correctly', () => {
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 }],
],
},
},
},
}),
},
},
});
const { container } = renderComponent();
expect(container.firstChild).toBeVisible();
expect(container.firstChild).toHaveClass('disabledStrikeThrough');
});
});

View file

@ -1,21 +1,8 @@
<script setup lang="ts">
import { computed, useCssModule } from 'vue';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { useCanvasNode } from '@/composables/useCanvasNode';
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(
() => false,
// @TODO Implement this
@ -31,7 +18,7 @@ const classes = computed(() => {
</script>
<template>
<div v-if="isVisible" :class="classes"></div>
<div :class="classes"></div>
</template>
<style lang="scss" module>

View file

@ -2,7 +2,11 @@
import { watch, computed, ref, onMounted } from 'vue';
import ExecutionsFilter from '@/components/executions/ExecutionsFilter.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 { useMessage } from '@/composables/useMessage';
import { useI18n } from '@/composables/useI18n';
@ -13,6 +17,8 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { useExecutionsStore } from '@/stores/executions.store';
import type { PermissionsRecord } from '@/permissions';
import { getResourcePermissions } from '@/permissions';
import { usePostHog } from '@/stores/posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
const props = withDefaults(
defineProps<{
@ -36,6 +42,8 @@ const i18n = useI18n();
const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore();
const executionsStore = useExecutionsStore();
const posthogStore = usePostHog();
const settingsStore = useSettingsStore();
const isMounted = 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(
() => props.executions,
() => {
@ -109,10 +123,18 @@ function toggleSelectExecution(execution: ExecutionSummary) {
}
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', {
interpolate: { count: selectedCount.value.toString() },
}),
]
.filter(Boolean)
.join(' ');
const deleteExecutions = await message.confirm(
confirmationText,
i18n.baseText('executionsList.confirmMessage.headline'),
{
type: 'warning',
@ -258,6 +280,26 @@ async function stopExecution(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 {
await executionsStore.deleteExecutions({ ids: [execution.id] });

View file

@ -72,9 +72,23 @@ const isAnnotationEnabled = computed(
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> {
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'),
]
.filter(Boolean)
.join(' ');
const deleteConfirmed = await message.confirm(
confirmationText,
locale.baseText('executionDetails.confirmMessage.headline'),
{
type: 'warning',

View file

@ -102,15 +102,6 @@ describe('useCanvasOperations', () => {
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', () => {
const nodeTypesStore = useNodeTypesStore();
const type = 'testTypeWithoutVersion';
@ -123,6 +114,25 @@ describe('useCanvasOperations', () => {
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', () => {
@ -616,7 +626,6 @@ describe('useCanvasOperations', () => {
deleteNode(id, { trackHistory: true });
expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(id);
expect(workflowsStore.removeNodeConnectionsById).toHaveBeenCalledWith(id);
expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(id);
expect(historyStore.pushCommandToUndo).toHaveBeenCalledWith(new RemoveNodeCommand(node));
});
@ -644,7 +653,6 @@ describe('useCanvasOperations', () => {
deleteNode(id, { trackHistory: false });
expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(id);
expect(workflowsStore.removeNodeConnectionsById).toHaveBeenCalledWith(id);
expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(id);
expect(historyStore.pushCommandToUndo).not.toHaveBeenCalled();
});
@ -714,7 +722,6 @@ describe('useCanvasOperations', () => {
deleteNode(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.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', () => {
it('should duplicate nodes', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);

View file

@ -236,7 +236,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
await renameNode(currentName, previousName);
}
function connectAdjacentNodes(id: string) {
function connectAdjacentNodes(id: string, { trackHistory = false } = {}) {
const node = workflowsStore.getNodeById(id);
if (!node) {
@ -262,6 +262,23 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
if (!outgoingNodeId) continue;
if (trackHistory) {
historyStore.pushCommandToUndo(
new AddConnectionCommand([
{
node: incomingConnection.node,
type,
index: 0,
},
{
node: outgoingConnection.node,
type,
index: 0,
},
]),
);
}
createConnection({
source: incomingNodeId,
sourceHandle: createCanvasConnectionHandleString({
@ -289,8 +306,13 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
historyStore.startRecordingUndo();
}
connectAdjacentNodes(id);
workflowsStore.removeNodeConnectionsById(id);
if (uiStore.lastInteractedWithNodeId === id) {
uiStore.lastInteractedWithNodeId = null;
}
connectAdjacentNodes(id, { trackHistory });
deleteConnectionsByNodeId(id, { trackHistory, trackBulk: false });
workflowsStore.removeNodeExecutionDataById(id);
workflowsStore.removeNodeById(id);
@ -423,16 +445,23 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
historyStore.stopRecordingUndo();
}
function requireNodeTypeDescription(type: INodeUi['type'], version?: INodeUi['typeVersion']) {
const nodeTypeDescription = nodeTypesStore.getNodeType(type, version);
if (!nodeTypeDescription) {
throw new Error(
i18n.baseText('nodeView.showMessage.addNodeButton.message', {
interpolate: { nodeTypeName: type },
}),
);
}
return nodeTypeDescription;
function requireNodeTypeDescription(
type: INodeUi['type'],
version?: INodeUi['typeVersion'],
): INodeTypeDescription {
return (
nodeTypesStore.getNodeType(type, version) ?? {
properties: [],
displayName: type,
name: type,
group: [],
description: '',
version: version ?? 1,
defaults: {},
inputs: [],
outputs: [],
}
);
}
async function addNodes(
@ -1135,6 +1164,72 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
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(
connection: Connection,
{ trackHistory = false, trackBulk = true } = {},
@ -1777,6 +1872,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
revertCreateConnection,
deleteConnection,
revertDeleteConnection,
deleteConnectionsByNodeId,
isConnectionAllowed,
importWorkflowData,
fetchWorkflowDataFromUrl,

View file

@ -1,4 +1,5 @@
import { createApp } from 'vue';
import * as Sentry from '@sentry/vue';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
@ -34,6 +35,11 @@ const pinia = createPinia();
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(PiniaVuePlugin);
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 { 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>({
map(pos, mapping) {
@ -121,20 +121,10 @@ function eventToCoord(event: MouseEvent): { x: number; y: number } {
return { x: event.clientX, y: event.clientY };
}
export async function dropInEditor(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);
}
const changes = view.state.changes({ from: dropPos, insert: valueToInsert });
const anchor = changes.mapPos(dropPos, -1);
const head = changes.mapPos(dropPos, 1);
function dropValueInEditor(view: EditorView, pos: number, value: string) {
const changes = view.state.changes({ from: pos, insert: value });
const anchor = changes.mapPos(pos, -1);
const head = changes.mapPos(pos, 1);
const selection = EditorSelection.single(anchor, head);
view.dispatch({
@ -144,10 +134,29 @@ export async function dropInEditor(view: EditorView, event: MouseEvent, value: s
});
setTimeout(() => view.focus());
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 {
return [dropCursorPos, drawDropCursor];
}

View file

@ -649,6 +649,7 @@
"executionDetails.confirmMessage.confirmButtonText": "Yes, delete",
"executionDetails.confirmMessage.headline": "Delete 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.executionFailed": "Execution failed",
"executionDetails.executionFailed.recoveredNodeTitle": "Cant show data",
@ -689,6 +690,8 @@
"executionsList.confirmMessage.confirmButtonText": "Yes, delete",
"executionsList.confirmMessage.headline": "Delete Executions?",
"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.error": "Error",
"executionsList.filters": "Filters",

View file

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

View file

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

View file

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

View file

@ -93,13 +93,13 @@ if (release && authToken) {
sentryVitePlugin({
org: 'n8nio',
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/
// and needs the `project:releases` and `org:read` scopes
authToken,
telemetry: false,
release,
release: {
name: release,
},
}),
);
}

View file

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

View file

@ -18,7 +18,7 @@ export async function pageList(
value: 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,
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 BasePaginatedFacebookResponse<TData> = BaseFacebookResponse<TData> & {
paging: { cursors: { before?: string; after?: string } };
paging: { cursors: { before?: string; after?: string }; next?: string };
};
export type FacebookAppWebhookSubscriptionsResponse = BaseFacebookResponse<

View file

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

View file

@ -143,6 +143,14 @@ const properties: INodeProperties[] = [
description:
"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;
rawOutput?: boolean;
useLegacySql?: boolean;
returnAsNumbers?: boolean;
};
const projectId = this.getNodeParameter('projectId', i, undefined, {
@ -263,6 +272,29 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
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));
} else {
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[]> {
const items = this.getInputData();
const nodeVersion = this.getNode().typeVersion;
const dataMode =
let dataMode =
nodeVersion < 4
? (this.getNodeParameter('dataMode', 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');
if (sheetData === undefined || !sheetData.length) {
dataMode = 'autoMapInputData';
}
if (nodeVersion >= 4.4 && dataMode !== 'autoMapInputData') {
//not possible to refresh columns when mode is autoMapInputData
if (sheetData?.[keyRowIndex - 1] === undefined) {

View file

@ -257,7 +257,7 @@ export async function execute(
}
}
const dataMode =
let dataMode =
nodeVersion < 4
? (this.getNodeParameter('dataMode', 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')) ?? [];
if (!sheetData[keyRowIndex] && dataMode !== 'autoMapInputData') {
throw new NodeOperationError(
this.getNode(),
`Could not retrieve the column names from row ${keyRowIndex + 1}`,
);
if (!sheetData.length) {
dataMode = 'autoMapInputData';
} else {
throw new NodeOperationError(
this.getNode(),
`Could not retrieve the column names from row ${keyRowIndex + 1}`,
);
}
}
columnNames = sheetData[keyRowIndex] ?? [];

View file

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

View file

@ -26,6 +26,13 @@ export const versionDescription: INodeTypeDescription = {
whenToDisplay: 'beforeExecution',
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: [
{

View file

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

View file

@ -141,7 +141,7 @@ export function prepareOutput(
) => NodeExecutionWithMetadata[],
itemData: IPairedItemData | IPairedItemData[],
) {
const returnData: INodeExecutionData[] = [];
let returnData: INodeExecutionData[] = [];
if (options.detailedOutput) {
response.forEach((entry, index) => {
@ -154,7 +154,7 @@ export function prepareOutput(
itemData,
});
returnData.push(...executionData);
returnData = returnData.concat(executionData);
});
} else {
response
@ -164,7 +164,7 @@ export function prepareOutput(
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 { listSearch } from './shared/methods';
import {
databaseUrlExtractionRegexp,
databaseUrlValidationRegexp,
idExtractionRegexp,
idValidationRegexp,
} from './shared/constants';
export class NotionTrigger implements INodeType {
description: INodeTypeDescription = {
@ -85,16 +91,14 @@ export class NotionTrigger implements INodeType {
{
type: 'regex',
properties: {
regex:
'(?: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}).*',
regex: databaseUrlValidationRegexp,
errorMessage: 'Not a valid Notion Database URL',
},
},
],
extractValue: {
type: 'regex',
regex:
'(?: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})',
regex: databaseUrlExtractionRegexp,
},
},
{
@ -106,15 +110,14 @@ export class NotionTrigger implements INodeType {
{
type: 'regex',
properties: {
regex:
'^(([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]*',
regex: idValidationRegexp,
errorMessage: 'Not a valid Notion Database ID',
},
},
],
extractValue: {
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, "")}}',
},

View file

@ -23,6 +23,7 @@ import moment from 'moment-timezone';
import { validate as uuidValidate } from 'uuid';
import set from 'lodash/set';
import { filters } from './descriptions/Filters';
import { blockUrlExtractionRegexp } from './constants';
function uuidValidateWithoutDashes(this: IExecuteFunctions, value: string) {
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);
if (match === null) {
const pageRegex =
/(?: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 pageRegex = new RegExp(blockUrlExtractionRegexp);
const pageMatch = (blockIdRLCData.value as string).match(pageRegex);
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 { blocks } from './Blocks';
import {
blockUrlExtractionRegexp,
blockUrlValidationRegexp,
idExtractionRegexp,
idValidationRegexp,
} from '../constants';
//RLC with fixed regex for blockId
const blockIdRLC: INodeProperties = {
@ -20,15 +26,14 @@ const blockIdRLC: INodeProperties = {
{
type: 'regex',
properties: {
regex:
'(?: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}).*',
regex: blockUrlValidationRegexp,
errorMessage: 'Not a valid Notion Block URL',
},
},
],
// extractValue: {
// 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',
properties: {
regex:
'(?: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}).*',
regex: blockUrlValidationRegexp,
errorMessage: 'Not a valid Notion Block URL',
},
},
],
extractValue: {
type: 'regex',
regex:
'(?: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})',
regex: blockUrlExtractionRegexp,
},
},
{
@ -122,15 +125,14 @@ export const blockFields: INodeProperties[] = [
{
type: 'regex',
properties: {
regex:
'^(([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]*',
regex: idValidationRegexp,
errorMessage: 'Not a valid Notion Block ID',
},
},
],
extractValue: {
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, "")}}',
},
@ -176,16 +178,14 @@ export const blockFields: INodeProperties[] = [
{
type: 'regex',
properties: {
regex:
'(?: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}).*',
regex: blockUrlValidationRegexp,
errorMessage: 'Not a valid Notion Block URL',
},
},
],
extractValue: {
type: 'regex',
regex:
'(?: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})',
regex: blockUrlExtractionRegexp,
},
},
{
@ -197,15 +197,14 @@ export const blockFields: INodeProperties[] = [
{
type: 'regex',
properties: {
regex:
'^(([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]*',
regex: idValidationRegexp,
errorMessage: 'Not a valid Notion Block ID',
},
},
],
extractValue: {
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, "")}}',
},

View file

@ -1,4 +1,10 @@
import type { IDisplayOptions, INodeProperties } from 'n8n-workflow';
import {
databaseUrlExtractionRegexp,
databaseUrlValidationRegexp,
idExtractionRegexp,
idValidationRegexp,
} from '../constants';
const colors = [
{
@ -221,16 +227,14 @@ const typeMention: INodeProperties[] = [
{
type: 'regex',
properties: {
regex:
'(?: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}).*',
regex: databaseUrlValidationRegexp,
errorMessage: 'Not a valid Notion Database URL',
},
},
],
extractValue: {
type: 'regex',
regex:
'(?: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})',
regex: databaseUrlExtractionRegexp,
},
},
{
@ -242,15 +246,14 @@ const typeMention: INodeProperties[] = [
{
type: 'regex',
properties: {
regex:
'^(([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]*',
regex: idValidationRegexp,
errorMessage: 'Not a valid Notion Database ID',
},
},
],
extractValue: {
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, "")}}',
},

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