mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge master
This commit is contained in:
commit
309932f461
32
.github/workflows/release-publish.yml
vendored
32
.github/workflows/release-publish.yml
vendored
|
@ -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}}
|
||||
|
|
43
CHANGELOG.md
43
CHANGELOG.md
|
@ -1,3 +1,46 @@
|
|||
# [1.61.0](https://github.com/n8n-io/n8n/compare/n8n@1.60.0...n8n@1.61.0) (2024-09-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Add executionData to expressions in pagination code ([#10926](https://github.com/n8n-io/n8n/issues/10926)) ([eac103e](https://github.com/n8n-io/n8n/commit/eac103e367d59a532b9ba12db78a0dd10aee62fb))
|
||||
* **core:** Fix webhook binary data max size configuration ([#10897](https://github.com/n8n-io/n8n/issues/10897)) ([693fb7e](https://github.com/n8n-io/n8n/commit/693fb7e580b7e030c86977bff6d319bbee4fcd62))
|
||||
* **core:** Remove subworkflow license check ([#10893](https://github.com/n8n-io/n8n/issues/10893)) ([0290e38](https://github.com/n8n-io/n8n/commit/0290e38f990275074eb7e7ccd0b41f1ae0215dd2))
|
||||
* **editor:** Credentials scopes and n8n scopes mix up ([#10930](https://github.com/n8n-io/n8n/issues/10930)) ([e069608](https://github.com/n8n-io/n8n/commit/e0696080227aee7ccb50d51a82873e8a1ba4667d))
|
||||
* **editor:** Fix design system form component sizing ([#10961](https://github.com/n8n-io/n8n/issues/10961)) ([cf153ea](https://github.com/n8n-io/n8n/commit/cf153ea085165115ee523fbb1bd32080dde47eda))
|
||||
* **editor:** Fix modal overflow when AI is enabled in code node ([#10887](https://github.com/n8n-io/n8n/issues/10887)) ([f9f303f](https://github.com/n8n-io/n8n/commit/f9f303f562084db8c8956da267680b1f935aa2df))
|
||||
* **editor:** Fix source control push modal checkboxes ([#10910](https://github.com/n8n-io/n8n/issues/10910)) ([8db8817](https://github.com/n8n-io/n8n/commit/8db88178511749b19a5878816ef062092fd9f2be))
|
||||
* **editor:** Fix styling and typography in AI Assistant chat ([#10895](https://github.com/n8n-io/n8n/issues/10895)) ([57ff3cc](https://github.com/n8n-io/n8n/commit/57ff3cc27b9470bfbe2486c3c1831c57f5a4075f))
|
||||
* **editor:** Prevent clipboard xss injection ([#10894](https://github.com/n8n-io/n8n/issues/10894)) ([e20ab59](https://github.com/n8n-io/n8n/commit/e20ab59c1dcf9da19a30268ce19930bfa7e38992))
|
||||
* **editor:** Prevent node name input in NDV to expand unnecessarily ([#10922](https://github.com/n8n-io/n8n/issues/10922)) ([a2237d1](https://github.com/n8n-io/n8n/commit/a2237d128ff6a4d65cd30325b6b9d9b765ca7be6))
|
||||
* **editor:** Update gird size when opening credentials support chat ([#10882](https://github.com/n8n-io/n8n/issues/10882)) ([b86fd80](https://github.com/n8n-io/n8n/commit/b86fd80fc9fe06011367ca04a75e4b52533db1fe))
|
||||
* **editor:** Use `:focus-visible` instead for `:focus` for buttons ([#10921](https://github.com/n8n-io/n8n/issues/10921)) ([bf28d09](https://github.com/n8n-io/n8n/commit/bf28d0965c46620a106c87037bafd2cf936f1050))
|
||||
* **editor:** Use correct output for connected nodes in schema view ([#10928](https://github.com/n8n-io/n8n/issues/10928)) ([ad60d49](https://github.com/n8n-io/n8n/commit/ad60d49b4251138a7c69cb5e9f00c3ef875486e0))
|
||||
* Enable Assistant on other credential views ([#10931](https://github.com/n8n-io/n8n/issues/10931)) ([557db9c](https://github.com/n8n-io/n8n/commit/557db9c170a89447ec9cc14aa1af51e5fd11dd92))
|
||||
* Ensure user id for early track events ([#10885](https://github.com/n8n-io/n8n/issues/10885)) ([23c09ea](https://github.com/n8n-io/n8n/commit/23c09eae4223545c717270a5cd305d2e57e1ad5b))
|
||||
* **Google Sheets Node:** Insert data if sheet is empty instead of error ([#10942](https://github.com/n8n-io/n8n/issues/10942)) ([c75990e](https://github.com/n8n-io/n8n/commit/c75990e0632c581384542610a886ef89621a9403))
|
||||
* Hide assistant button when showing Click to connect ([#10932](https://github.com/n8n-io/n8n/issues/10932)) ([d74cff2](https://github.com/n8n-io/n8n/commit/d74cff20301f285588f93207f29660d25fdbc8da))
|
||||
* **HTTP Request Node:** Do not modify request object when sanitizing message for UI ([#10923](https://github.com/n8n-io/n8n/issues/10923)) ([8cc10cc](https://github.com/n8n-io/n8n/commit/8cc10cc2c1869b9abcafd157e41be65ce2b6f499))
|
||||
* **MQTT Node:** Close connection if connection attempt fails ([#10873](https://github.com/n8n-io/n8n/issues/10873)) ([ee7147c](https://github.com/n8n-io/n8n/commit/ee7147c6b3b053ac8fc317319ab257204e599f16))
|
||||
* **MySQL Node:** Fix "Maximum call stack size exceeded" error when handling a large number of rows ([#10965](https://github.com/n8n-io/n8n/issues/10965)) ([62159bd](https://github.com/n8n-io/n8n/commit/62159bd71c9a0303b597a68113e0ac50473ee8d4))
|
||||
* **Notion Node:** Allow UUID v8 in notion id checks ([#10938](https://github.com/n8n-io/n8n/issues/10938)) ([46beda0](https://github.com/n8n-io/n8n/commit/46beda05f6771c31bcf0b6a781976d8261079a66))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Brandfetch Node:** Update to use new API ([#10877](https://github.com/n8n-io/n8n/issues/10877)) ([08ba9a3](https://github.com/n8n-io/n8n/commit/08ba9a36a43b6c84f69bb04fa4d6419a7a4adddf))
|
||||
* **editor:** Setup Sentry integration ([#10945](https://github.com/n8n-io/n8n/issues/10945)) ([6de4dff](https://github.com/n8n-io/n8n/commit/6de4dfff87e4da888567081a9928d9682bdea11d))
|
||||
* **editor:** Show a notice before deleting annotated executions ([#10934](https://github.com/n8n-io/n8n/issues/10934)) ([dcc1c72](https://github.com/n8n-io/n8n/commit/dcc1c72fc4b56c3252183541b22da801804d4f79))
|
||||
* Page size 1 option ([#10957](https://github.com/n8n-io/n8n/issues/10957)) ([bdc0622](https://github.com/n8n-io/n8n/commit/bdc0622f59e98c9e6c542f5cb59a2dbd9008ba96))
|
||||
* **Slack Node:** Add option to hide workflow link on message update ([#10927](https://github.com/n8n-io/n8n/issues/10927)) ([422c946](https://github.com/n8n-io/n8n/commit/422c9463c8d931a728615a1fe5a10f05a96ecaa2))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **editor:** Use virtual scrolling in `RunDataJson.vue` ([#10838](https://github.com/n8n-io/n8n/issues/10838)) ([f5474ff](https://github.com/n8n-io/n8n/commit/f5474ff79198a2f5a145d0a9df1bb651ea677ec5))
|
||||
|
||||
|
||||
|
||||
# [1.60.0](https://github.com/n8n-io/n8n/compare/n8n@1.59.0...n8n@1.60.0) (2024-09-18)
|
||||
|
||||
|
||||
|
|
|
@ -1,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",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
12
packages/@n8n/config/src/configs/sentry.config.ts
Normal file
12
packages/@n8n/config/src/configs/sentry.config.ts
Normal 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 = '';
|
||||
}
|
|
@ -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 = '/';
|
||||
|
|
|
@ -221,6 +221,10 @@ describe('GlobalConfig', () => {
|
|||
},
|
||||
},
|
||||
},
|
||||
sentry: {
|
||||
backendDsn: '',
|
||||
frontendDsn: '',
|
||||
},
|
||||
};
|
||||
|
||||
it('should use all default values when no env variables are defined', () => {
|
||||
|
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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 |
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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 |
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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 |
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
16
packages/cli/src/events/events.controller.ts
Normal file
16
packages/cli/src/events/events.controller.ts
Normal 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 });
|
||||
}
|
||||
}
|
|
@ -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`.",
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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' });
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
// ----------------------------------
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
|
|
|
@ -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`);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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({});
|
||||
});
|
||||
});
|
26
packages/core/src/PartialExecutionUtils/cleanRunData.ts
Normal file
26
packages/core/src/PartialExecutionUtils/cleanRunData.ts
Normal 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;
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
6
packages/editor-ui/src/api/events.ts
Normal file
6
packages/editor-ui/src/api/events.ts
Normal 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');
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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],
|
||||
() => {
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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]));
|
||||
};
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -54,6 +54,7 @@ describe('ParameterInput.vue', () => {
|
|||
type: 'test',
|
||||
typeVersion: 1,
|
||||
},
|
||||
isNDVDataEmpty: vi.fn(() => false),
|
||||
};
|
||||
mockNodeTypesState = {
|
||||
allNodeTypes: [],
|
||||
|
|
|
@ -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) => {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -313,7 +313,6 @@ onBeforeUnmount(() => {
|
|||
@open:contextmenu="onOpenContextMenuFromNode"
|
||||
>
|
||||
<NodeIcon
|
||||
v-if="nodeTypeDescription"
|
||||
:node-type="nodeTypeDescription"
|
||||
:size="nodeIconSize"
|
||||
:shrink="false"
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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] });
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
|
|
@ -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": "Can’t 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",
|
||||
|
|
2
packages/editor-ui/src/shims.d.ts
vendored
2
packages/editor-ui/src/shims.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -17,6 +17,6 @@
|
|||
},
|
||||
"alias": ["SFTP", "FTP", "Binary", "File", "Transfer"],
|
||||
"subcategories": {
|
||||
"Core Nodes": ["Files"]
|
||||
"Core Nodes": ["Files", "Helpers"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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) {
|
||||
|
|
|
@ -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] ?? [];
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
@ -327,7 +327,7 @@ export class MySqlV1 implements INodeType {
|
|||
{ itemData: { item: index } },
|
||||
);
|
||||
|
||||
collection.push(...executionData);
|
||||
collection = collection.concat(executionData);
|
||||
|
||||
return collection;
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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, "")}}',
|
||||
},
|
||||
|
|
|
@ -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) {
|
||||
|
|
15
packages/nodes-base/nodes/Notion/shared/constants.ts
Normal file
15
packages/nodes-base/nodes/Notion/shared/constants.ts
Normal 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}.*`;
|
|
@ -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, "")}}',
|
||||
},
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue