Merge remote-tracking branch 'origin/master' into release/1.0.1

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2023-07-05 20:01:24 +02:00
commit e33cc2c27c
193 changed files with 16701 additions and 4702 deletions

View file

@ -17,6 +17,18 @@ const filterAsync = async (asyncPredicate, arr) => {
return filterResults.filter(({shouldKeep}) => shouldKeep).map(({item}) => item); return filterResults.filter(({shouldKeep}) => shouldKeep).map(({item}) => item);
} }
const isAbstractClass = (node) => {
if (ts.isClassDeclaration(node)) {
return node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AbstractKeyword) || false;
}
return false;
}
const isAbstractMethod = (node) => {
return ts.isMethodDeclaration(node) && Boolean(node.modifiers?.find((modifier) => modifier.kind === ts.SyntaxKind.AbstractKeyword));
}
// Function to check if a file has a function declaration, function expression, object method or class // Function to check if a file has a function declaration, function expression, object method or class
const hasFunctionOrClass = async filePath => { const hasFunctionOrClass = async filePath => {
const fileContent = await readFileAsync(filePath, 'utf-8'); const fileContent = await readFileAsync(filePath, 'utf-8');
@ -24,7 +36,13 @@ const hasFunctionOrClass = async filePath => {
let hasFunctionOrClass = false; let hasFunctionOrClass = false;
const visit = node => { const visit = node => {
if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isMethodDeclaration(node) || ts.isClassDeclaration(node)) { if (
ts.isFunctionDeclaration(node)
|| ts.isFunctionExpression(node)
|| ts.isArrowFunction(node)
|| (ts.isMethodDeclaration(node) && !isAbstractMethod(node))
|| (ts.isClassDeclaration(node) && !isAbstractClass(node))
) {
hasFunctionOrClass = true; hasFunctionOrClass = true;
} }
node.forEachChild(visit); node.forEachChild(visit);

View file

@ -0,0 +1,18 @@
name: Check Issue Template
on:
issues:
types: [opened, edited]
jobs:
check-issue:
name: Check Issue Template
runs-on: ubuntu-latest
steps:
- name: Run Check Issue Template
uses: n8n-io/GH-actions-playground@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -22,7 +22,8 @@
# [1.0.0](https://github.com/n8n-io/n8n/compare/n8n@0.234.0...n8n@1.0.0) (2023-06-27) # [1.0.0](https://github.com/n8n-io/n8n/compare/n8n@0.234.0...n8n@1.0.0) (2023-06-27)
### ⚠ BREAKING CHANGES
### ⚠️ BREAKING CHANGES
* **core** Docker containers now run as the user `node` instead of `root` ([#6365](https://github.com/n8n-io/n8n/pull/6365)) ([f636616](https://github.com/n8n-io/n8n/commit/f6366160a476f42cb0612d10c5777a154d8665dd)) * **core** Docker containers now run as the user `node` instead of `root` ([#6365](https://github.com/n8n-io/n8n/pull/6365)) ([f636616](https://github.com/n8n-io/n8n/commit/f6366160a476f42cb0612d10c5777a154d8665dd))
* **core** Drop `debian` and `rhel7` images ([#6365](https://github.com/n8n-io/n8n/pull/6365)) ([f636616](https://github.com/n8n-io/n8n/commit/f6366160a476f42cb0612d10c5777a154d8665dd)) * **core** Drop `debian` and `rhel7` images ([#6365](https://github.com/n8n-io/n8n/pull/6365)) ([f636616](https://github.com/n8n-io/n8n/commit/f6366160a476f42cb0612d10c5777a154d8665dd))
* **core** Drop support for deprecated `WEBHOOK_TUNNEL_URL` env variable ([#6363](https://github.com/n8n-io/n8n/pull/6363)) * **core** Drop support for deprecated `WEBHOOK_TUNNEL_URL` env variable ([#6363](https://github.com/n8n-io/n8n/pull/6363))
@ -54,11 +55,68 @@
* **core:** Remove conditional defaults in V1 release ([#6363](https://github.com/n8n-io/n8n/issues/6363)) ([f636616](https://github.com/n8n-io/n8n/commit/f6366160a476f42cb0612d10c5777a154d8665dd)) * **core:** Remove conditional defaults in V1 release ([#6363](https://github.com/n8n-io/n8n/issues/6363)) ([f636616](https://github.com/n8n-io/n8n/commit/f6366160a476f42cb0612d10c5777a154d8665dd))
* **editor:** Add v1 banner ([#6443](https://github.com/n8n-io/n8n/issues/6443)) ([0fe415a](https://github.com/n8n-io/n8n/commit/0fe415add2baa8e70e29087f7a90312bd1ab38af)) * **editor:** Add v1 banner ([#6443](https://github.com/n8n-io/n8n/issues/6443)) ([0fe415a](https://github.com/n8n-io/n8n/commit/0fe415add2baa8e70e29087f7a90312bd1ab38af))
* **editor:** SQL editor overhaul ([#6282](https://github.com/n8n-io/n8n/issues/6282)) ([beedfb6](https://github.com/n8n-io/n8n/commit/beedfb609ccde2ef202e08566580a2e1a6b6eafa)) * **editor:** SQL editor overhaul ([#6282](https://github.com/n8n-io/n8n/issues/6282)) ([beedfb6](https://github.com/n8n-io/n8n/commit/beedfb609ccde2ef202e08566580a2e1a6b6eafa))
* **Code Node:** Python support is now enabled by default. ([#6363](https://github.com/n8n-io/n8n/pull/6363))
* **HTTP Request Node:** Notice about dev console ([#6516](https://github.com/n8n-io/n8n/issues/6516)) ([d431117](https://github.com/n8n-io/n8n/commit/d431117c9e5db9ff0ec6a1e7371bbf58698957c9)) * **HTTP Request Node:** Notice about dev console ([#6516](https://github.com/n8n-io/n8n/issues/6516)) ([d431117](https://github.com/n8n-io/n8n/commit/d431117c9e5db9ff0ec6a1e7371bbf58698957c9))
# [0.236.0](https://github.com/n8n-io/n8n/compare/n8n@0.235.0...n8n@0.236.0) (2023-07-05)
### Bug Fixes
* **Brevo Node:** Rename SendInBlue node to Brevo node ([#6521](https://github.com/n8n-io/n8n/issues/6521)) ([e63b398](https://github.com/n8n-io/n8n/commit/e63b3982d200ade34461b9159eb1e988f494c025))
* **core:** Fix credentials test ([#6569](https://github.com/n8n-io/n8n/issues/6569)) ([1abd172](https://github.com/n8n-io/n8n/commit/1abd172f73e171e37c4cc3ccfaa395c6a46bdf48))
* **core:** Fix migrations for MySQL/MariaDB ([#6591](https://github.com/n8n-io/n8n/issues/6591)) ([29882a6](https://github.com/n8n-io/n8n/commit/29882a6f39dddcd1c8c107c20a548ce8dc665cba))
* **core:** Improve the performance of last 2 sqlite migrations ([#6522](https://github.com/n8n-io/n8n/issues/6522)) ([31cba87](https://github.com/n8n-io/n8n/commit/31cba87d307183d613890c7e6d627636b5280b52))
* **core:** Remove typeorm patches, but still enforce transactions on every migration ([#6594](https://github.com/n8n-io/n8n/issues/6594)) ([9def7a7](https://github.com/n8n-io/n8n/commit/9def7a729b52cd6b4698c47e190e9e2bd7894da5)), closes [#6519](https://github.com/n8n-io/n8n/issues/6519)
* **core:** Use owners file to export wf owners ([#6547](https://github.com/n8n-io/n8n/issues/6547)) ([4b755fb](https://github.com/n8n-io/n8n/commit/4b755fb0b441a37eb804c9e70d4b071a341f7155))
* **editor:** Show retry information in execution list only when it exists ([#6587](https://github.com/n8n-io/n8n/issues/6587)) ([3ca66be](https://github.com/n8n-io/n8n/commit/3ca66be38082e7a3866d53d07328be58e913067f))
* **Salesforce Node:** Fix typo for adding a contact to a campaign ([#6598](https://github.com/n8n-io/n8n/issues/6598)) ([7ffe3cb](https://github.com/n8n-io/n8n/commit/7ffe3cb36adeecaca6cc6ddf067a701ee55c18d1))
* **Strapi Node:** Fix issue with pagination ([#4991](https://github.com/n8n-io/n8n/issues/4991)) ([54444fa](https://github.com/n8n-io/n8n/commit/54444fa388da12d75553e66e53a8cf6f8a99b6fc))
* **XML Node:** Fix issue with not returning valid data ([#6565](https://github.com/n8n-io/n8n/issues/6565)) ([cdd215f](https://github.com/n8n-io/n8n/commit/cdd215f642b47413c05f229e641074d0d4048f68))
### Features
* Add crowd.dev node and trigger node ([#6082](https://github.com/n8n-io/n8n/issues/6082)) ([238a78f](https://github.com/n8n-io/n8n/commit/238a78f0582dbf439a9799de0edcb2e9bef29978))
* Add various source control improvements ([#6533](https://github.com/n8n-io/n8n/issues/6533)) ([68fdc20](https://github.com/n8n-io/n8n/commit/68fdc2078928be478a286774f2889feba1c3f5fe))
* **HTTP Request Node:** New http request generic custom auth credential ([#5798](https://github.com/n8n-io/n8n/issues/5798)) ([b17b458](https://github.com/n8n-io/n8n/commit/b17b4582a059104665888a2369c3e2256db4c1ed))
* **Microsoft To Do Node:** Add an option to set a reminder when creating a task ([#5757](https://github.com/n8n-io/n8n/issues/5757)) ([b19833d](https://github.com/n8n-io/n8n/commit/b19833d673bd554ba86c0b234e8d13633912563a))
* **Notion Node:** Add option to update icon when updating a page ([#5670](https://github.com/n8n-io/n8n/issues/5670)) ([225e849](https://github.com/n8n-io/n8n/commit/225e849960ce65d7f85b482f05fb3d7ffb4f9427))
* **Strava Node:** Add hide_from_home field in Activity Update ([#5883](https://github.com/n8n-io/n8n/issues/5883)) ([7495e31](https://github.com/n8n-io/n8n/commit/7495e31a5b25e97683c7ea38225ba253d8fae8b7))
* **Twitter Node:** Node overhaul ([#4788](https://github.com/n8n-io/n8n/issues/4788)) ([42721db](https://github.com/n8n-io/n8n/commit/42721dba80077fb796086a2bf0ecce256bf3a50f))
# [0.235.0](https://github.com/n8n-io/n8n/compare/n8n@0.234.0...n8n@0.235.0) (2023-06-28)
### Bug Fixes
* **core:** Add empty credential value marker to show empty pw field ([#6532](https://github.com/n8n-io/n8n/issues/6532)) ([9294e2d](https://github.com/n8n-io/n8n/commit/9294e2da3c7c99c2099f5865e610fa7217bf06be))
* **core:** All migrations should run in a transaction ([#6519](https://github.com/n8n-io/n8n/issues/6519)) ([e152cfe](https://github.com/n8n-io/n8n/commit/e152cfe27cf3396f4b278614f1d46d9dd723f36e))
* **core:** Rename to credential_stubs and variable_stubs.json ([#6528](https://github.com/n8n-io/n8n/issues/6528)) ([b06462f](https://github.com/n8n-io/n8n/commit/b06462f4415bd1143a00b4a66e6e626da8c52196))
* **Edit Image Node:** Fix transparent operation ([#6513](https://github.com/n8n-io/n8n/issues/6513)) ([4a4bcbc](https://github.com/n8n-io/n8n/commit/4a4bcbca298bf90c54d3597103e6a231855abbd2))
* **editor:** Add default author name and email to source control settings ([#6543](https://github.com/n8n-io/n8n/issues/6543)) ([e1a02c7](https://github.com/n8n-io/n8n/commit/e1a02c76257de30e08878279dea33d7854d46938))
* **editor:** Change default branchColor and remove label ([#6541](https://github.com/n8n-io/n8n/issues/6541)) ([186271e](https://github.com/n8n-io/n8n/commit/186271e939bca19ec9c94d9455e9430d8b8cf9d7))
* **Google Drive Node:** URL parsing ([#6527](https://github.com/n8n-io/n8n/issues/6527)) ([d9ed0b3](https://github.com/n8n-io/n8n/commit/d9ed0b31b538320a67ee4e5c0cae34656c9f4334))
* **Google Sheets Node:** Incorrect read of 0 and false ([#6525](https://github.com/n8n-io/n8n/issues/6525)) ([806d134](https://github.com/n8n-io/n8n/commit/806d13460240abe94843e569b1820cd8d0d8edd1))
* **Merge Node:** Enrich input 2 fix ([#6526](https://github.com/n8n-io/n8n/issues/6526)) ([c82c7f1](https://github.com/n8n-io/n8n/commit/c82c7f19128df3a11d6d0f18e8d8dab57e6a3b8f))
* **Notion Node:** Version fix ([#6531](https://github.com/n8n-io/n8n/issues/6531)) ([38dc784](https://github.com/n8n-io/n8n/commit/38dc784d2eed25aae777c5c3c3fda1a35e20bd24))
* **Sendy Node:** Fix issue with brand id not being sent ([#6530](https://github.com/n8n-io/n8n/issues/6530)) ([2e8dfb8](https://github.com/n8n-io/n8n/commit/2e8dfb86d4636781b319d6190e8be12e7661ee16))
### Features
* Add missing input panels to some trigger nodes ([#6518](https://github.com/n8n-io/n8n/issues/6518)) ([fdf8a42](https://github.com/n8n-io/n8n/commit/fdf8a428ed38bb3ceb2bc0e50b002b34843d8fc4))
* **editor:** Prevent saving of workflow when canvas is loading ([#6497](https://github.com/n8n-io/n8n/issues/6497)) ([f89ef83](https://github.com/n8n-io/n8n/commit/f89ef83c766fafb1d0497ed91a74b93e8d2af1ec))
* **editor:** SQL editor overhaul ([#6282](https://github.com/n8n-io/n8n/issues/6282)) ([beedfb6](https://github.com/n8n-io/n8n/commit/beedfb609ccde2ef202e08566580a2e1a6b6eafa))
* **Google Drive Node:** Overhaul ([#5941](https://github.com/n8n-io/n8n/issues/5941)) ([d70a1cb](https://github.com/n8n-io/n8n/commit/d70a1cb0c82ee0a4b92776684c6c9079020d028f))
* **HTTP Request Node:** Notice about dev console ([#6516](https://github.com/n8n-io/n8n/issues/6516)) ([d431117](https://github.com/n8n-io/n8n/commit/d431117c9e5db9ff0ec6a1e7371bbf58698957c9))
* **Matrix Node:** Allow setting filename if the binary data has none ([#6536](https://github.com/n8n-io/n8n/issues/6536)) ([8b76e98](https://github.com/n8n-io/n8n/commit/8b76e980852062b192a95593035697c43d6f808e))
# [0.234.0](https://github.com/n8n-io/n8n/compare/n8n@0.233.0...n8n@0.234.0) (2023-06-22) # [0.234.0](https://github.com/n8n-io/n8n/compare/n8n@0.233.0...n8n@0.234.0) (2023-06-22)

View file

@ -54,8 +54,8 @@ The most important directories:
## Development setup ## Development setup
If you want to change or extend n8n you have to make sure that all needed If you want to change or extend n8n you have to make sure that all the needed
dependencies are installed and the packages get linked correctly. Here a short guide on how that can be done: dependencies are installed and the packages get linked correctly. Here's a short guide on how that can be done:
### Requirements ### Requirements
@ -69,7 +69,7 @@ dependencies are installed and the packages get linked correctly. Here a short g
##### pnpm workspaces ##### pnpm workspaces
n8n is split up in different modules which are all in a single mono repository. n8n is split up into different modules which are all in a single mono repository.
To facilitate the module management, [pnpm workspaces](https://pnpm.io/workspaces) are used. To facilitate the module management, [pnpm workspaces](https://pnpm.io/workspaces) are used.
This automatically sets up file-links between modules which depend on each other. This automatically sets up file-links between modules which depend on each other.
@ -113,24 +113,24 @@ No additional packages required.
> **IMPORTANT**: All the steps below have to get executed at least once to get the development setup up and running! > **IMPORTANT**: All the steps below have to get executed at least once to get the development setup up and running!
Now that everything n8n requires to run is installed the actual n8n code can be Now that everything n8n requires to run is installed, the actual n8n code can be
checked out and set up: checked out and set up:
1. [Fork](https://guides.github.com/activities/forking/#fork) the n8n repository 1. [Fork](https://guides.github.com/activities/forking/#fork) the n8n repository.
2. Clone your forked repository 2. Clone your forked repository:
``` ```
git clone https://github.com/<your_github_username>/n8n.git git clone https://github.com/<your_github_username>/n8n.git
``` ```
3. Go into repository folder 3. Go into repository folder:
``` ```
cd n8n cd n8n
``` ```
4. Add the original n8n repository as `upstream` to your forked repository 4. Add the original n8n repository as `upstream` to your forked repository:
``` ```
git remote add upstream https://github.com/n8n-io/n8n.git git remote add upstream https://github.com/n8n-io/n8n.git
@ -172,13 +172,13 @@ automatically build your code, restart the backend and refresh the frontend
pnpm dev pnpm dev
``` ```
1. Hack, hack, hack 1. Hack, hack, hack
1. Check if everything still runs in production mode 1. Check if everything still runs in production mode:
``` ```
pnpm build pnpm build
pnpm start pnpm start
``` ```
1. Create tests 1. Create tests
1. Run all [tests](#test-suite) 1. Run all [tests](#test-suite):
``` ```
pnpm test pnpm test
``` ```
@ -198,7 +198,7 @@ tests of all packages.
## Releasing ## Releasing
To start a release, trigger [this workflow](https://github.com/n8n-io/n8n/actions/workflows/release-create-pr.yml) with the SemVer release type, and select a branch to cut this release from. This workflow will then To start a release, trigger [this workflow](https://github.com/n8n-io/n8n/actions/workflows/release-create-pr.yml) with the SemVer release type, and select a branch to cut this release from. This workflow will then:
1. Bump versions of packages that have changed or have dependencies that have changed 1. Bump versions of packages that have changed or have dependencies that have changed
2. Update the Changelog 2. Update the Changelog
@ -206,7 +206,7 @@ To start a release, trigger [this workflow](https://github.com/n8n-io/n8n/action
4. Create a new pull-request to track any further changes that need to be included in this release 4. Create a new pull-request to track any further changes that need to be included in this release
Once ready to release, simply merge the pull-request. Once ready to release, simply merge the pull-request.
This triggers [another workflow](https://github.com/n8n-io/n8n/actions/workflows/release-publish.yml), that will This triggers [another workflow](https://github.com/n8n-io/n8n/actions/workflows/release-publish.yml), that will:
1. Build and publish the packages that have a new version in this release 1. Build and publish the packages that have a new version in this release
2. Create a new tag, and GitHub release from squashed release commit 2. Create a new tag, and GitHub release from squashed release commit
@ -226,4 +226,4 @@ That we do not have any potential problems later it is sadly necessary to sign a
We used the most simple one that exists. It is from [Indie Open Source](https://indieopensource.com/forms/cla) which uses plain English and is literally only a few lines long. We used the most simple one that exists. It is from [Indie Open Source](https://indieopensource.com/forms/cla) which uses plain English and is literally only a few lines long.
A bot will automatically comment on the pull request once it got opened asking for the agreement to be signed. Before it did not get signed it is sadly not possible to merge it in. Once a pull request is opened, an automated bot will promptly leave a comment requesting the agreement to be signed. The pull request can only be merged once the signature is obtained.

View file

@ -5,6 +5,7 @@ import {
SCHEDULE_TRIGGER_NODE_NAME, SCHEDULE_TRIGGER_NODE_NAME,
} from '../constants'; } from '../constants';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows';
const NEW_WORKFLOW_NAME = 'Something else'; const NEW_WORKFLOW_NAME = 'Something else';
const IMPORT_WORKFLOW_URL = 'https://gist.githubusercontent.com/OlegIvaniv/010bd3f45c8a94f8eb7012e663a8b671/raw/3afea1aec15573cc168d9af7e79395bd76082906/test-workflow.json'; const IMPORT_WORKFLOW_URL = 'https://gist.githubusercontent.com/OlegIvaniv/010bd3f45c8a94f8eb7012e663a8b671/raw/3afea1aec15573cc168d9af7e79395bd76082906/test-workflow.json';
@ -12,6 +13,7 @@ const DUPLICATE_WORKFLOW_NAME = 'Duplicated workflow';
const DUPLICATE_WORKFLOW_TAG = 'Duplicate'; const DUPLICATE_WORKFLOW_TAG = 'Duplicate';
const WorkflowPage = new WorkflowPageClass(); const WorkflowPage = new WorkflowPageClass();
const WorkflowPages = new WorkflowsPageClass();
describe('Workflow Actions', () => { describe('Workflow Actions', () => {
beforeEach(() => { beforeEach(() => {
@ -62,6 +64,42 @@ describe('Workflow Actions', () => {
.should('eq', NEW_WORKFLOW_NAME); .should('eq', NEW_WORKFLOW_NAME);
}); });
it('should not save workflow if canvas is loading', () => {
let interceptCalledCount = 0;
// There's no way in Cypress to check if intercept was not called
// so we'll count the number of times it was called
cy.intercept('PATCH', '/rest/workflows/*', () => {
interceptCalledCount++;
}).as('saveWorkflow');
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
cy.intercept(
{
url: '/rest/workflows/*',
method: 'GET',
middleware: true,
},
(req) => {
// Delay the response to give time for the save to be triggered
req.on('response', async (res) => {
await new Promise((resolve) => setTimeout(resolve, 2000))
res.send();
})
}
)
cy.reload();
cy.get('.el-loading-mask').should('exist');
cy.get('body').type(META_KEY, { release: false }).type('s');
cy.get('body').type(META_KEY, { release: false }).type('s');
cy.get('body').type(META_KEY, { release: false }).type('s');
cy.wrap(null).then(() => expect(interceptCalledCount).to.eq(0));
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
cy.get('body').type(META_KEY, { release: false }).type('s');
cy.wait('@saveWorkflow');
cy.wrap(null).then(() => expect(interceptCalledCount).to.eq(1));
})
it('should copy nodes', () => { it('should copy nodes', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
@ -106,64 +144,70 @@ describe('Workflow Actions', () => {
}); });
it('should update workflow settings', () => { it('should update workflow settings', () => {
WorkflowPage.actions.visit(); cy.visit(WorkflowPages.url);
// Open settings dialog WorkflowPages.getters.workflowCards().then((cards) => {
WorkflowPage.actions.saveWorkflowOnButtonClick(); const totalWorkflows = cards.length;
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click(); WorkflowPage.actions.visit();
WorkflowPage.getters.workflowMenuItemSettings().should('be.visible'); // Open settings dialog
WorkflowPage.getters.workflowMenuItemSettings().click(); WorkflowPage.actions.saveWorkflowOnButtonClick();
// Change all settings WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowSettingsErrorWorkflowSelect().find('li').should('have.length', 7); WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters WorkflowPage.getters.workflowMenuItemSettings().should('be.visible');
.workflowSettingsErrorWorkflowSelect() WorkflowPage.getters.workflowMenuItemSettings().click();
.find('li') // Change all settings
.last() // totalWorkflows + 1 (current workflow) + 1 (no workflow option)
.click({ force: true }); WorkflowPage.getters.workflowSettingsErrorWorkflowSelect().find('li').should('have.length', totalWorkflows + 2);
WorkflowPage.getters.workflowSettingsTimezoneSelect().find('li').should('exist'); WorkflowPage.getters
WorkflowPage.getters.workflowSettingsTimezoneSelect().find('li').eq(1).click({ force: true }); .workflowSettingsErrorWorkflowSelect()
WorkflowPage.getters .find('li')
.workflowSettingsSaveFiledExecutionsSelect() .last()
.find('li') .click({ force: true });
.should('have.length', 3); WorkflowPage.getters.workflowSettingsTimezoneSelect().find('li').should('exist');
WorkflowPage.getters WorkflowPage.getters.workflowSettingsTimezoneSelect().find('li').eq(1).click({ force: true });
.workflowSettingsSaveFiledExecutionsSelect() WorkflowPage.getters
.find('li') .workflowSettingsSaveFiledExecutionsSelect()
.last() .find('li')
.click({ force: true }); .should('have.length', 3);
WorkflowPage.getters WorkflowPage.getters
.workflowSettingsSaveSuccessExecutionsSelect() .workflowSettingsSaveFiledExecutionsSelect()
.find('li') .find('li')
.should('have.length', 3); .last()
WorkflowPage.getters .click({ force: true });
.workflowSettingsSaveSuccessExecutionsSelect() WorkflowPage.getters
.find('li') .workflowSettingsSaveSuccessExecutionsSelect()
.last() .find('li')
.click({ force: true }); .should('have.length', 3);
WorkflowPage.getters WorkflowPage.getters
.workflowSettingsSaveManualExecutionsSelect() .workflowSettingsSaveSuccessExecutionsSelect()
.find('li') .find('li')
.should('have.length', 3); .last()
WorkflowPage.getters .click({ force: true });
.workflowSettingsSaveManualExecutionsSelect() WorkflowPage.getters
.find('li') .workflowSettingsSaveManualExecutionsSelect()
.last() .find('li')
.click({ force: true }); .should('have.length', 3);
WorkflowPage.getters WorkflowPage.getters
.workflowSettingsSaveExecutionProgressSelect() .workflowSettingsSaveManualExecutionsSelect()
.find('li') .find('li')
.should('have.length', 3); .last()
WorkflowPage.getters .click({ force: true });
.workflowSettingsSaveExecutionProgressSelect() WorkflowPage.getters
.find('li') .workflowSettingsSaveExecutionProgressSelect()
.last() .find('li')
.click({ force: true }); .should('have.length', 3);
WorkflowPage.getters.workflowSettingsTimeoutWorkflowSwitch().click(); WorkflowPage.getters
WorkflowPage.getters.workflowSettingsTimeoutForm().find('input').first().type('1'); .workflowSettingsSaveExecutionProgressSelect()
// Save settings .find('li')
WorkflowPage.getters.workflowSettingsSaveButton().click(); .last()
WorkflowPage.getters.workflowSettingsModal().should('not.exist'); .click({ force: true });
WorkflowPage.getters.successToast().should('exist'); WorkflowPage.getters.workflowSettingsTimeoutWorkflowSwitch().click();
WorkflowPage.getters.workflowSettingsTimeoutForm().find('input').first().type('1');
// Save settings
WorkflowPage.getters.workflowSettingsSaveButton().click();
WorkflowPage.getters.workflowSettingsModal().should('not.exist');
WorkflowPage.getters.successToast().should('exist');
})
}); });
it('should not be able to delete unsaved workflow', () => { it('should not be able to delete unsaved workflow', () => {

View file

@ -177,7 +177,7 @@ export class WorkflowPage extends BasePage {
}, },
saveWorkflowUsingKeyboardShortcut: () => { saveWorkflowUsingKeyboardShortcut: () => {
cy.intercept('POST', '/rest/workflows').as('createWorkflow'); cy.intercept('POST', '/rest/workflows').as('createWorkflow');
cy.get('body').type('{meta}', { release: false }).type('s'); cy.get('body').type(META_KEY, { release: false }).type('s');
}, },
deleteNode: (name: string) => { deleteNode: (name: string) => {
this.getters.canvasNodeByName(name).first().click(); this.getters.canvasNodeByName(name).first().click();

View file

@ -36,8 +36,10 @@ export class WorkflowsPage extends BasePage {
cy.visit(this.url); cy.visit(this.url);
this.getters.workflowCardActions(name).click(); this.getters.workflowCardActions(name).click();
this.getters.workflowDeleteButton().click(); this.getters.workflowDeleteButton().click();
cy.intercept('DELETE', '/rest/workflows/*').as('deleteWorkflow');
cy.get('button').contains('delete').click(); cy.get('button').contains('delete').click();
cy.wait('@deleteWorkflow');
}, },
}; };
} }

View file

@ -93,8 +93,7 @@
"element-ui@2.15.12": "patches/element-ui@2.15.12.patch", "element-ui@2.15.12": "patches/element-ui@2.15.12.patch",
"typedi@0.10.0": "patches/typedi@0.10.0.patch", "typedi@0.10.0": "patches/typedi@0.10.0.patch",
"@sentry/cli@2.17.0": "patches/@sentry__cli@2.17.0.patch", "@sentry/cli@2.17.0": "patches/@sentry__cli@2.17.0.patch",
"pkce-challenge@3.0.0": "patches/pkce-challenge@3.0.0.patch", "pkce-challenge@3.0.0": "patches/pkce-challenge@3.0.0.patch"
"typeorm@0.3.12": "patches/typeorm@0.3.12.patch"
} }
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/client-oauth2", "name": "@n8n/client-oauth2",
"version": "0.3.0", "version": "0.4.0",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View file

@ -57,7 +57,6 @@ export class CodeFlow {
opts?: Partial<ClientOAuth2Options>, opts?: Partial<ClientOAuth2Options>,
): Promise<ClientOAuth2Token> { ): Promise<ClientOAuth2Token> {
const options = { ...this.client.options, ...opts }; const options = { ...this.client.options, ...opts };
expects(options, 'clientId', 'accessTokenUri'); expects(options, 'clientId', 'accessTokenUri');
const url = uri instanceof URL ? uri : new URL(uri, DEFAULT_URL_BASE); const url = uri instanceof URL ? uri : new URL(uri, DEFAULT_URL_BASE);

View file

@ -21,7 +21,6 @@ export class CredentialsFlow {
*/ */
async getToken(opts?: Partial<ClientOAuth2Options>): Promise<ClientOAuth2Token> { async getToken(opts?: Partial<ClientOAuth2Options>): Promise<ClientOAuth2Token> {
const options = { ...this.client.options, ...opts }; const options = { ...this.client.options, ...opts };
expects(options, 'clientId', 'clientSecret', 'accessTokenUri'); expects(options, 'clientId', 'clientSecret', 'accessTokenUri');
const body: CredentialsFlowBody = { const body: CredentialsFlowBody = {

View file

@ -2,6 +2,19 @@
This list shows all the versions which include breaking changes and how to upgrade. This list shows all the versions which include breaking changes and how to upgrade.
## 0.234.0
### What changed?
This release introduces two irreversible changes:
* The n8n database will use strings instead of numeric values to identify workflows and credentials
* Execution data is split into a separate database table
### When is action necessary?
It will not be possible to read a n8n@0.234.0 database with older versions of n8n, so we recommend that you take a full backup before migrating.
## 0.232.0 ## 0.232.0
### What changed? ### What changed?

View file

@ -106,13 +106,13 @@ export class WaitingWebhooks {
workflow, workflow,
workflow.getNode(lastNodeExecuted) as INode, workflow.getNode(lastNodeExecuted) as INode,
additionalData, additionalData,
).filter((webhook) => { ).find((webhook) => {
return ( return (
webhook.httpMethod === httpMethod && webhook.httpMethod === httpMethod &&
webhook.path === path && webhook.path === path &&
webhook.webhookDescription.restartWebhook === true webhook.webhookDescription.restartWebhook === true
); );
})[0]; });
if (webhookData === undefined) { if (webhookData === undefined) {
// If no data got found it means that the execution can not be started via a webhook. // If no data got found it means that the execution can not be started via a webhook.

View file

@ -4,6 +4,7 @@ import { v4 as uuid } from 'uuid';
import config from '@/config'; import config from '@/config';
import type { Role } from '@db/entities/Role'; import type { Role } from '@db/entities/Role';
import { RoleRepository, SettingsRepository, UserRepository } from '@db/repositories'; import { RoleRepository, SettingsRepository, UserRepository } from '@db/repositories';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { hashPassword } from '@/UserManagement/UserManagementHelper'; import { hashPassword } from '@/UserManagement/UserManagementHelper';
import { eventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; import { eventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
import { License } from '@/License'; import { License } from '@/License';
@ -66,6 +67,7 @@ export class E2EController {
private roleRepo: RoleRepository, private roleRepo: RoleRepository,
private settingsRepo: SettingsRepository, private settingsRepo: SettingsRepository,
private userRepo: UserRepository, private userRepo: UserRepository,
private workflowRunner: ActiveWorkflowRunner,
) { ) {
license.isFeatureEnabled = (feature: LICENSE_FEATURES) => license.isFeatureEnabled = (feature: LICENSE_FEATURES) =>
this.enabledFeatures[feature] ?? false; this.enabledFeatures[feature] ?? false;
@ -76,6 +78,7 @@ export class E2EController {
config.set('ui.banners.v1.dismissed', true); config.set('ui.banners.v1.dismissed', true);
this.resetFeatures(); this.resetFeatures();
await this.resetLogStreaming(); await this.resetLogStreaming();
await this.removeActiveWorkflows();
await this.truncateAll(); await this.truncateAll();
await this.setupUserManagement(req.body.owner, req.body.members); await this.setupUserManagement(req.body.owner, req.body.members);
} }
@ -92,6 +95,11 @@ export class E2EController {
} }
} }
private async removeActiveWorkflows() {
this.workflowRunner.removeAllQueuedWorkflowActivations();
await this.workflowRunner.removeAll();
}
private async resetLogStreaming() { private async resetLogStreaming() {
for (const id in eventBus.destinations) { for (const id in eventBus.destinations) {
await eventBus.removeDestination(id); await eventBus.removeDestination(id);

View file

@ -7,7 +7,7 @@ import type {
INodeCredentialTestResult, INodeCredentialTestResult,
INodeProperties, INodeProperties,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { deepCopy, LoggerProxy, NodeHelpers } from 'n8n-workflow'; import { CREDENTIAL_EMPTY_VALUE, deepCopy, LoggerProxy, NodeHelpers } from 'n8n-workflow';
import { Container } from 'typedi'; import { Container } from 'typedi';
import type { FindManyOptions, FindOptionsWhere } from 'typeorm'; import type { FindManyOptions, FindOptionsWhere } from 'typeorm';
import { In } from 'typeorm'; import { In } from 'typeorm';
@ -300,7 +300,11 @@ export class CredentialsService {
for (const dataKey of Object.keys(copiedData)) { for (const dataKey of Object.keys(copiedData)) {
// The frontend only cares that this value isn't falsy. // The frontend only cares that this value isn't falsy.
if (dataKey === 'oauthTokenData') { if (dataKey === 'oauthTokenData') {
copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE; if (copiedData[dataKey].toString().length > 0) {
copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE;
} else {
copiedData[dataKey] = CREDENTIAL_EMPTY_VALUE;
}
continue; continue;
} }
const prop = properties.find((v) => v.name === dataKey); const prop = properties.find((v) => v.name === dataKey);
@ -308,8 +312,11 @@ export class CredentialsService {
continue; continue;
} }
if (prop.typeOptions?.password) { if (prop.typeOptions?.password) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access if (copiedData[dataKey].toString().length > 0) {
copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE; copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE;
} else {
copiedData[dataKey] = CREDENTIAL_EMPTY_VALUE;
}
} }
} }
@ -321,7 +328,7 @@ export class CredentialsService {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
for (const [key, value] of Object.entries(unmerged)) { for (const [key, value] of Object.entries(unmerged)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (value === CREDENTIAL_BLANKING_VALUE) { if (value === CREDENTIAL_BLANKING_VALUE || value === CREDENTIAL_EMPTY_VALUE) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
unmerged[key] = replacement[key]; unmerged[key] = replacement[key];
} else if ( } else if (

View file

@ -1,6 +1,8 @@
import type { MigrationContext, ReversibleMigration } from '@db/types'; import type { MigrationContext, ReversibleMigration } from '@db/types';
export class AddUserSettings1652367743993 implements ReversibleMigration { export class AddUserSettings1652367743993 implements ReversibleMigration {
transaction = false as const;
async up({ queryRunner, tablePrefix }: MigrationContext) { async up({ queryRunner, tablePrefix }: MigrationContext) {
await queryRunner.query( await queryRunner.query(
`CREATE TABLE "temporary_user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, "settings" text, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`, `CREATE TABLE "temporary_user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, "settings" text, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`,

View file

@ -1,6 +1,8 @@
import type { MigrationContext, ReversibleMigration } from '@db/types'; import type { MigrationContext, ReversibleMigration } from '@db/types';
export class AddAPIKeyColumn1652905585850 implements ReversibleMigration { export class AddAPIKeyColumn1652905585850 implements ReversibleMigration {
transaction = false as const;
async up({ queryRunner, tablePrefix }: MigrationContext) { async up({ queryRunner, tablePrefix }: MigrationContext) {
await queryRunner.query( await queryRunner.query(
`CREATE TABLE "temporary_user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, "settings" text, "apiKey" varchar, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`, `CREATE TABLE "temporary_user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, "settings" text, "apiKey" varchar, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`,

View file

@ -1,6 +1,8 @@
import type { MigrationContext, ReversibleMigration } from '@db/types'; import type { MigrationContext, ReversibleMigration } from '@db/types';
export class DeleteExecutionsWithWorkflows1673268682475 implements ReversibleMigration { export class DeleteExecutionsWithWorkflows1673268682475 implements ReversibleMigration {
transaction = false as const;
async up({ queryRunner, tablePrefix }: MigrationContext) { async up({ queryRunner, tablePrefix }: MigrationContext) {
const workflowIds = (await queryRunner.query(` const workflowIds = (await queryRunner.query(`
SELECT id FROM "${tablePrefix}workflow_entity" SELECT id FROM "${tablePrefix}workflow_entity"

View file

@ -1,18 +1,31 @@
import { statSync } from 'fs';
import path from 'path';
import { UserSettings } from 'n8n-core';
import type { MigrationContext, IrreversibleMigration } from '@db/types'; import type { MigrationContext, IrreversibleMigration } from '@db/types';
import config from '@/config';
import { copyTable } from '@/databases/utils/migrationHelpers';
export class MigrateIntegerKeysToString1690000000002 implements IrreversibleMigration { export class MigrateIntegerKeysToString1690000000002 implements IrreversibleMigration {
async up({ queryRunner, tablePrefix }: MigrationContext) { transaction = false as const;
async up(context: MigrationContext) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
await pruneExecutionsData(context);
const { queryRunner, tablePrefix } = context;
await queryRunner.query(` await queryRunner.query(`
CREATE TABLE "${tablePrefix}TMP_workflow_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text, "connections" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "settings" text, "staticData" text, "pinData" text, "versionId" varchar(36), "triggerCount" integer NOT NULL DEFAULT 0);`); CREATE TABLE "${tablePrefix}TMP_workflow_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text, "connections" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "settings" text, "staticData" text, "pinData" text, "versionId" varchar(36), "triggerCount" integer NOT NULL DEFAULT 0);`);
await queryRunner.query( await queryRunner.query(
`INSERT INTO "${tablePrefix}TMP_workflow_entity" (id, name, active, nodes, connections, createdAt, updatedAt, settings, staticData, pinData, triggerCount, versionId) SELECT id, name, active, nodes, connections, createdAt, updatedAt, settings, staticData, pinData, triggerCount, versionId FROM "${tablePrefix}workflow_entity";`, `INSERT INTO "${tablePrefix}TMP_workflow_entity" (id, name, active, nodes, connections, createdAt, updatedAt, settings, staticData, pinData, triggerCount, versionId) SELECT id, name, active, nodes, connections, createdAt, updatedAt, settings, staticData, pinData, triggerCount, versionId FROM "${tablePrefix}workflow_entity";`,
); );
await queryRunner.query(`DROP TABLE "${tablePrefix}workflow_entity";`); await queryRunner.query(`DROP TABLE "${tablePrefix}workflow_entity";`);
await queryRunner.query(`ALTER TABLE "${tablePrefix}TMP_workflow_entity" RENAME TO "${tablePrefix}workflow_entity"; await queryRunner.query(
`); `ALTER TABLE "${tablePrefix}TMP_workflow_entity" RENAME TO "${tablePrefix}workflow_entity"`,
);
await queryRunner.query(` await queryRunner.query(`
CREATE TABLE "${tablePrefix}TMP_tag_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')));`); CREATE TABLE "${tablePrefix}TMP_tag_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')));`);
await queryRunner.query( await queryRunner.query(
`INSERT INTO "${tablePrefix}TMP_tag_entity" SELECT * FROM "${tablePrefix}tag_entity";`, `INSERT INTO "${tablePrefix}TMP_tag_entity" SELECT * FROM "${tablePrefix}tag_entity";`,
); );
@ -22,7 +35,7 @@ CREATE TABLE "${tablePrefix}TMP_tag_entity" ("id" varchar(36) PRIMARY KEY NOT NU
); );
await queryRunner.query(` await queryRunner.query(`
CREATE TABLE "${tablePrefix}TMP_workflows_tags" ("workflowId" varchar(36) NOT NULL, "tagId" integer NOT NULL, CONSTRAINT "FK_${tablePrefix}workflows_tags_workflow_entity" FOREIGN KEY ("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_${tablePrefix}workflows_tags_tag_entity" FOREIGN KEY ("tagId") REFERENCES "${tablePrefix}tag_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("workflowId", "tagId"));`); CREATE TABLE "${tablePrefix}TMP_workflows_tags" ("workflowId" varchar(36) NOT NULL, "tagId" integer NOT NULL, CONSTRAINT "FK_${tablePrefix}workflows_tags_workflow_entity" FOREIGN KEY ("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_${tablePrefix}workflows_tags_tag_entity" FOREIGN KEY ("tagId") REFERENCES "${tablePrefix}tag_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("workflowId", "tagId"));`);
await queryRunner.query( await queryRunner.query(
`INSERT INTO "${tablePrefix}TMP_workflows_tags" SELECT * FROM "${tablePrefix}workflows_tags";`, `INSERT INTO "${tablePrefix}TMP_workflows_tags" SELECT * FROM "${tablePrefix}workflows_tags";`,
); );
@ -105,9 +118,7 @@ CREATE TABLE "${tablePrefix}TMP_workflows_tags" ("workflowId" varchar(36) NOT NU
"data" text NOT NULL, "status" varchar, "data" text NOT NULL, "status" varchar,
FOREIGN KEY("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE FOREIGN KEY("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE
);`); );`);
await queryRunner.query( await copyTable({ tablePrefix, queryRunner }, 'execution_entity', 'TMP_execution_entity');
`INSERT INTO "${tablePrefix}TMP_execution_entity" SELECT * FROM "${tablePrefix}execution_entity";`,
);
await queryRunner.query(`DROP TABLE "${tablePrefix}execution_entity";`); await queryRunner.query(`DROP TABLE "${tablePrefix}execution_entity";`);
await queryRunner.query( await queryRunner.query(
`ALTER TABLE "${tablePrefix}TMP_execution_entity" RENAME TO "${tablePrefix}execution_entity";`, `ALTER TABLE "${tablePrefix}TMP_execution_entity" RENAME TO "${tablePrefix}execution_entity";`,
@ -175,3 +186,44 @@ CREATE TABLE "${tablePrefix}TMP_workflows_tags" ("workflowId" varchar(36) NOT NU
); );
} }
} }
const DESIRED_DATABASE_FILE_SIZE = 1 * 1024 * 1024 * 1024; // 1 GB
const migrationsPruningEnabled = process.env.MIGRATIONS_PRUNING_ENABLED === 'true';
function getSqliteDbFileSize(): number {
const filename = path.resolve(
UserSettings.getUserN8nFolderPath(),
config.getEnv('database.sqlite.database'),
);
const { size } = statSync(filename);
return size;
}
const pruneExecutionsData = async ({ queryRunner, tablePrefix }: MigrationContext) => {
if (migrationsPruningEnabled) {
const dbFileSize = getSqliteDbFileSize();
if (dbFileSize < DESIRED_DATABASE_FILE_SIZE) {
console.log(`DB Size not large enough to prune: ${dbFileSize}`);
return;
}
console.time('pruningData');
const counting = (await queryRunner.query(
`select count(id) as rows from "${tablePrefix}execution_entity";`,
)) as Array<{ rows: number }>;
const averageExecutionSize = dbFileSize / counting[0].rows;
const numberOfExecutionsToKeep = Math.floor(DESIRED_DATABASE_FILE_SIZE / averageExecutionSize);
const query = `SELECT id FROM "${tablePrefix}execution_entity" ORDER BY id DESC limit ${numberOfExecutionsToKeep}, 1`;
const idToKeep = await queryRunner
.query(query)
.then((rows: Array<{ id: number }>) => rows[0].id);
const removalQuery = `DELETE FROM "${tablePrefix}execution_entity" WHERE id < ${idToKeep} and status IN ('success')`;
await queryRunner.query(removalQuery);
console.timeEnd('pruningData');
} else {
console.log('Pruning was requested, but was not enabled');
}
};

View file

@ -1,4 +1,5 @@
import type { MigrationContext, ReversibleMigration } from '@/databases/types'; import type { MigrationContext, ReversibleMigration } from '@/databases/types';
import { copyTable } from '@/databases/utils/migrationHelpers';
export class SeparateExecutionData1690000000010 implements ReversibleMigration { export class SeparateExecutionData1690000000010 implements ReversibleMigration {
async up({ queryRunner, tablePrefix }: MigrationContext): Promise<void> { async up({ queryRunner, tablePrefix }: MigrationContext): Promise<void> {
@ -11,13 +12,12 @@ export class SeparateExecutionData1690000000010 implements ReversibleMigration {
)`, )`,
); );
await queryRunner.query( await copyTable(
`INSERT INTO "${tablePrefix}execution_data" ( { tablePrefix, queryRunner },
"executionId", 'execution_entity',
"workflowData", 'execution_data',
"data") ['id', 'workflowData', 'data'],
SELECT "id", "workflowData", "data" FROM "${tablePrefix}execution_entity" ['executionId', 'workflowData', 'data'],
`,
); );
await queryRunner.query( await queryRunner.query(

View file

@ -12,15 +12,19 @@ export interface MigrationContext {
migrationName: string; migrationName: string;
} }
type MigrationFn = (ctx: MigrationContext) => Promise<void>; export type MigrationFn = (ctx: MigrationContext) => Promise<void>;
export interface ReversibleMigration { export interface BaseMigration {
up: MigrationFn; up: MigrationFn;
down?: MigrationFn | never;
transaction?: false;
}
export interface ReversibleMigration extends BaseMigration {
down: MigrationFn; down: MigrationFn;
} }
export interface IrreversibleMigration { export interface IrreversibleMigration extends BaseMigration {
up: MigrationFn;
down?: never; down?: never;
} }

View file

@ -1,11 +1,10 @@
/* eslint-disable no-await-in-loop */
import { readFileSync, rmSync } from 'fs'; import { readFileSync, rmSync } from 'fs';
import { UserSettings } from 'n8n-core'; import { UserSettings } from 'n8n-core';
import type { QueryRunner } from 'typeorm/query-runner/QueryRunner'; import type { QueryRunner } from 'typeorm/query-runner/QueryRunner';
import config from '@/config'; import config from '@/config';
import { getLogger } from '@/Logger'; import { getLogger } from '@/Logger';
import { inTest } from '@/constants'; import { inTest } from '@/constants';
import type { Migration, MigrationContext } from '@db/types'; import type { BaseMigration, Migration, MigrationContext, MigrationFn } from '@db/types';
const logger = getLogger(); const logger = getLogger();
@ -39,30 +38,47 @@ export function loadSurveyFromDisk(): string | null {
} }
} }
let logFinishTimeout: NodeJS.Timeout; let runningMigrations = false;
export function logMigrationStart(migrationName: string, disableLogging = inTest): void { function logMigrationStart(migrationName: string): void {
if (disableLogging) return; if (inTest) return;
if (!logFinishTimeout) { if (!runningMigrations) {
logger.warn('Migrations in progress, please do NOT stop the process.'); logger.warn('Migrations in progress, please do NOT stop the process.');
runningMigrations = true;
} }
logger.debug(`Starting migration ${migrationName}`); logger.debug(`Starting migration ${migrationName}`);
clearTimeout(logFinishTimeout);
} }
export function logMigrationEnd(migrationName: string, disableLogging = inTest): void { function logMigrationEnd(migrationName: string): void {
if (disableLogging) return; if (inTest) return;
logger.debug(`Finished migration ${migrationName}`); logger.debug(`Finished migration ${migrationName}`);
logFinishTimeout = setTimeout(() => {
logger.warn('Migrations finished.');
}, 100);
} }
const runDisablingForeignKeys = async (
migration: BaseMigration,
context: MigrationContext,
fn: MigrationFn,
) => {
const { dbType, queryRunner } = context;
if (dbType !== 'sqlite') throw new Error('Disabling transactions only available in sqlite');
await queryRunner.query('PRAGMA foreign_keys=OFF');
await queryRunner.startTransaction();
try {
await fn.call(migration, context);
await queryRunner.commitTransaction();
} catch (e) {
try {
await queryRunner.rollbackTransaction();
} catch {}
throw e;
} finally {
await queryRunner.query('PRAGMA foreign_keys=ON');
}
};
export const wrapMigration = (migration: Migration) => { export const wrapMigration = (migration: Migration) => {
const dbType = config.getEnv('database.type'); const dbType = config.getEnv('database.type');
const dbName = config.getEnv(`database.${dbType === 'mariadb' ? 'mysqldb' : dbType}.database`); const dbName = config.getEnv(`database.${dbType === 'mariadb' ? 'mysqldb' : dbType}.database`);
@ -78,17 +94,58 @@ export const wrapMigration = (migration: Migration) => {
const { up, down } = migration.prototype; const { up, down } = migration.prototype;
Object.assign(migration.prototype, { Object.assign(migration.prototype, {
async up(queryRunner: QueryRunner) { async up(this: BaseMigration, queryRunner: QueryRunner) {
logMigrationStart(migrationName); logMigrationStart(migrationName);
await up.call(this, { queryRunner, ...context }); if (this.transaction === false) {
await runDisablingForeignKeys(this, { queryRunner, ...context }, up);
} else {
await up.call(this, { queryRunner, ...context });
}
logMigrationEnd(migrationName); logMigrationEnd(migrationName);
}, },
async down(queryRunner: QueryRunner) { async down(this: BaseMigration, queryRunner: QueryRunner) {
await down?.call(this, { queryRunner, ...context }); if (down) {
if (this.transaction === false) {
await runDisablingForeignKeys(this, { queryRunner, ...context }, up);
} else {
await down.call(this, { queryRunner, ...context });
}
}
}, },
}); });
}; };
export const copyTable = async (
{ tablePrefix, queryRunner }: Pick<MigrationContext, 'queryRunner' | 'tablePrefix'>,
fromTable: string,
toTable: string,
fromFields: string[] = [],
toFields: string[] = [],
batchSize = 10,
) => {
const driver = queryRunner.connection.driver;
fromTable = driver.escape(`${tablePrefix}${fromTable}`);
toTable = driver.escape(`${tablePrefix}${toTable}`);
const fromFieldsStr = fromFields.length
? fromFields.map((f) => driver.escape(f)).join(', ')
: '*';
const toFieldsStr = toFields.length
? `(${toFields.map((f) => driver.escape(f)).join(', ')})`
: '';
const total = await queryRunner
.query(`SELECT COUNT(*) as count from ${fromTable}`)
.then((rows: Array<{ count: number }>) => rows[0].count);
let migrated = 0;
while (migrated < total) {
await queryRunner.query(
`INSERT INTO ${toTable} ${toFieldsStr} SELECT ${fromFieldsStr} FROM ${fromTable} LIMIT ${migrated}, ${batchSize}`,
);
migrated += batchSize;
}
};
function batchQuery(query: string, limit: number, offset = 0): string { function batchQuery(query: string, limit: number, offset = 0): string {
return ` return `
${query} ${query}

View file

@ -2,9 +2,10 @@ export const SOURCE_CONTROL_PREFERENCES_DB_KEY = 'features.sourceControl';
export const SOURCE_CONTROL_GIT_FOLDER = 'git'; export const SOURCE_CONTROL_GIT_FOLDER = 'git';
export const SOURCE_CONTROL_GIT_KEY_COMMENT = 'n8n deploy key'; export const SOURCE_CONTROL_GIT_KEY_COMMENT = 'n8n deploy key';
export const SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER = 'workflows'; export const SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER = 'workflows';
export const SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER = 'credentials'; export const SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER = 'credential_stubs';
export const SOURCE_CONTROL_VARIABLES_EXPORT_FILE = 'variables.json'; export const SOURCE_CONTROL_VARIABLES_EXPORT_FILE = 'variable_stubs.json';
export const SOURCE_CONTROL_TAGS_EXPORT_FILE = 'tags.json'; export const SOURCE_CONTROL_TAGS_EXPORT_FILE = 'tags.json';
export const SOURCE_CONTROL_OWNERS_EXPORT_FILE = 'owners.json';
export const SOURCE_CONTROL_SSH_FOLDER = 'ssh'; export const SOURCE_CONTROL_SSH_FOLDER = 'ssh';
export const SOURCE_CONTROL_SSH_KEY_NAME = 'key'; export const SOURCE_CONTROL_SSH_KEY_NAME = 'key';
export const SOURCE_CONTROL_DEFAULT_BRANCH = 'main'; export const SOURCE_CONTROL_DEFAULT_BRANCH = 'main';

View file

@ -32,6 +32,8 @@ import type {
import { SourceControlPreferencesService } from './sourceControlPreferences.service.ee'; import { SourceControlPreferencesService } from './sourceControlPreferences.service.ee';
import { writeFileSync } from 'fs'; import { writeFileSync } from 'fs';
import { SourceControlImportService } from './sourceControlImport.service.ee'; import { SourceControlImportService } from './sourceControlImport.service.ee';
import type { WorkflowEntity } from '../../databases/entities/WorkflowEntity';
import type { CredentialsEntity } from '../../databases/entities/CredentialsEntity';
@Service() @Service()
export class SourceControlService { export class SourceControlService {
private sshKeyName: string; private sshKeyName: string;
@ -252,6 +254,7 @@ export class SourceControlService {
...status.modified, ...status.modified,
]); ]);
} }
mergedFileNames.add(this.sourceControlExportService.getOwnersPath());
const deletedFiles = new Set<string>(status.deleted); const deletedFiles = new Set<string>(status.deleted);
deletedFiles.forEach((e) => mergedFileNames.delete(e)); deletedFiles.forEach((e) => mergedFileNames.delete(e));
await this.unstage(); await this.unstage();
@ -285,6 +288,20 @@ export class SourceControlService {
let conflict = false; let conflict = false;
let status: SourceControlledFileStatus = 'unknown'; let status: SourceControlledFileStatus = 'unknown';
let type: SourceControlledFileType = 'file'; let type: SourceControlledFileType = 'file';
let updatedAt = '';
const allWorkflows: Map<string, WorkflowEntity> = new Map();
(await Db.collections.Workflow.find({ select: ['id', 'name', 'updatedAt'] })).forEach(
(workflow) => {
allWorkflows.set(workflow.id, workflow);
},
);
const allCredentials: Map<string, CredentialsEntity> = new Map();
(await Db.collections.Credentials.find({ select: ['id', 'name', 'updatedAt'] })).forEach(
(credential) => {
allCredentials.set(credential.id, credential);
},
);
// initialize status from git status result // initialize status from git status result
if (statusResult.not_added.find((e) => e === fileName)) status = 'new'; if (statusResult.not_added.find((e) => e === fileName)) status = 'new';
@ -303,14 +320,14 @@ export class SourceControlService {
.replace(/[\/,\\]/, '') .replace(/[\/,\\]/, '')
.replace('.json', ''); .replace('.json', '');
if (location === 'remote') { if (location === 'remote') {
const existingWorkflow = await Db.collections.Workflow.find({ const existingWorkflow = allWorkflows.get(id);
where: { id }, if (existingWorkflow) {
}); name = existingWorkflow.name;
if (existingWorkflow?.length > 0) { updatedAt = existingWorkflow.updatedAt.toISOString();
name = existingWorkflow[0].name;
} }
} else { } else {
name = '(deleted)'; name = '(deleted)';
// todo: once we have audit log, this deletion date could be looked up
} }
} else { } else {
const workflow = await this.sourceControlExportService.getWorkflowFromFile(fileName); const workflow = await this.sourceControlExportService.getWorkflowFromFile(fileName);
@ -326,6 +343,11 @@ export class SourceControlService {
id = workflow.id; id = workflow.id;
name = workflow.name; name = workflow.name;
} }
const existingWorkflow = allWorkflows.get(id);
if (existingWorkflow) {
name = existingWorkflow.name;
updatedAt = existingWorkflow.updatedAt.toISOString();
}
} }
} }
if (fileName.startsWith(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER)) { if (fileName.startsWith(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER)) {
@ -336,11 +358,10 @@ export class SourceControlService {
.replace(/[\/,\\]/, '') .replace(/[\/,\\]/, '')
.replace('.json', ''); .replace('.json', '');
if (location === 'remote') { if (location === 'remote') {
const existingCredential = await Db.collections.Credentials.find({ const existingCredential = allCredentials.get(id);
where: { id }, if (existingCredential) {
}); name = existingCredential.name;
if (existingCredential?.length > 0) { updatedAt = existingCredential.updatedAt.toISOString();
name = existingCredential[0].name;
} }
} else { } else {
name = '(deleted)'; name = '(deleted)';
@ -359,6 +380,11 @@ export class SourceControlService {
id = credential.id; id = credential.id;
name = credential.name; name = credential.name;
} }
const existingCredential = allCredentials.get(id);
if (existingCredential) {
name = existingCredential.name;
updatedAt = existingCredential.updatedAt.toISOString();
}
} }
} }
@ -369,9 +395,15 @@ export class SourceControlService {
} }
if (fileName.startsWith(SOURCE_CONTROL_TAGS_EXPORT_FILE)) { if (fileName.startsWith(SOURCE_CONTROL_TAGS_EXPORT_FILE)) {
const lastUpdatedTag = await Db.collections.Tag.find({
order: { updatedAt: 'DESC' },
take: 1,
select: ['updatedAt'],
});
id = 'tags'; id = 'tags';
name = 'tags'; name = 'tags';
type = 'tags'; type = 'tags';
updatedAt = lastUpdatedTag[0]?.updatedAt.toISOString();
} }
if (!id) return; if (!id) return;
@ -384,6 +416,7 @@ export class SourceControlService {
status, status,
location, location,
conflict, conflict,
updatedAt,
}; };
} }

View file

@ -3,6 +3,7 @@ import path from 'path';
import { import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
SOURCE_CONTROL_GIT_FOLDER, SOURCE_CONTROL_GIT_FOLDER,
SOURCE_CONTROL_OWNERS_EXPORT_FILE,
SOURCE_CONTROL_TAGS_EXPORT_FILE, SOURCE_CONTROL_TAGS_EXPORT_FILE,
SOURCE_CONTROL_VARIABLES_EXPORT_FILE, SOURCE_CONTROL_VARIABLES_EXPORT_FILE,
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
@ -50,6 +51,10 @@ export class SourceControlExportService {
return path.join(this.gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE); return path.join(this.gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE);
} }
getOwnersPath(): string {
return path.join(this.gitFolder, SOURCE_CONTROL_OWNERS_EXPORT_FILE);
}
getVariablesPath(): string { getVariablesPath(): string {
return path.join(this.gitFolder, SOURCE_CONTROL_VARIABLES_EXPORT_FILE); return path.join(this.gitFolder, SOURCE_CONTROL_VARIABLES_EXPORT_FILE);
} }
@ -160,7 +165,6 @@ export class SourceControlExportService {
connections: e.workflow?.connections, connections: e.workflow?.connections,
settings: e.workflow?.settings, settings: e.workflow?.settings,
triggerCount: e.workflow?.triggerCount, triggerCount: e.workflow?.triggerCount,
owner: e.user.email,
versionId: e.workflow?.versionId, versionId: e.workflow?.versionId,
}; };
LoggerProxy.debug(`Writing workflow ${e.workflowId} to ${fileName}`); LoggerProxy.debug(`Writing workflow ${e.workflowId} to ${fileName}`);
@ -186,6 +190,11 @@ export class SourceControlExportService {
const removedFiles = await this.rmDeletedWorkflowsFromExportFolder(sharedWorkflows); const removedFiles = await this.rmDeletedWorkflowsFromExportFolder(sharedWorkflows);
// write the workflows to the export folder as json files // write the workflows to the export folder as json files
await this.writeExportableWorkflowsToExportFolder(sharedWorkflows); await this.writeExportableWorkflowsToExportFolder(sharedWorkflows);
// write list of owners to file
const ownersFileName = this.getOwnersPath();
const owners: Record<string, string> = {};
sharedWorkflows.forEach((e) => (owners[e.workflowId] = e.user.email));
await fsWriteFile(ownersFileName, JSON.stringify(owners, null, 2));
return { return {
count: sharedWorkflows.length, count: sharedWorkflows.length,
folder: this.workflowExportFolder, folder: this.workflowExportFolder,
@ -280,7 +289,10 @@ export class SourceControlExportService {
} else if (typeof data[key] === 'object') { } else if (typeof data[key] === 'object') {
data[key] = this.replaceCredentialData(data[key] as ICredentialDataDecryptedObject); data[key] = this.replaceCredentialData(data[key] as ICredentialDataDecryptedObject);
} else if (typeof data[key] === 'string') { } else if (typeof data[key] === 'string') {
data[key] = (data[key] as string)?.startsWith('={{') ? data[key] : ''; data[key] =
(data[key] as string)?.startsWith('={{') && (data[key] as string)?.includes('$secret')
? data[key]
: '';
} else if (typeof data[key] === 'number') { } else if (typeof data[key] === 'number') {
// TODO: leaving numbers in for now, but maybe we should remove them // TODO: leaving numbers in for now, but maybe we should remove them
continue; continue;

View file

@ -3,6 +3,7 @@ import path from 'path';
import { import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
SOURCE_CONTROL_GIT_FOLDER, SOURCE_CONTROL_GIT_FOLDER,
SOURCE_CONTROL_OWNERS_EXPORT_FILE,
SOURCE_CONTROL_TAGS_EXPORT_FILE, SOURCE_CONTROL_TAGS_EXPORT_FILE,
SOURCE_CONTROL_VARIABLES_EXPORT_FILE, SOURCE_CONTROL_VARIABLES_EXPORT_FILE,
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
@ -14,15 +15,12 @@ import { readFile as fsReadFile } from 'fs/promises';
import { Credentials, UserSettings } from 'n8n-core'; import { Credentials, UserSettings } from 'n8n-core';
import type { IWorkflowToImport } from '@/Interfaces'; import type { IWorkflowToImport } from '@/Interfaces';
import type { ExportableCredential } from './types/exportableCredential'; import type { ExportableCredential } from './types/exportableCredential';
import { SharedWorkflow } from '@/databases/entities/SharedWorkflow';
import { CredentialsEntity } from '@/databases/entities/CredentialsEntity';
import { Variables } from '@/databases/entities/Variables'; import { Variables } from '@/databases/entities/Variables';
import type { ImportResult } from './types/importResult'; import type { ImportResult } from './types/importResult';
import { UM_FIX_INSTRUCTION } from '@/commands/BaseCommand'; import { UM_FIX_INSTRUCTION } from '@/commands/BaseCommand';
import { SharedCredentials } from '@/databases/entities/SharedCredentials'; import { SharedCredentials } from '@/databases/entities/SharedCredentials';
import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; import type { WorkflowTagMapping } from '@/databases/entities/WorkflowTagMapping';
import { WorkflowTagMapping } from '@/databases/entities/WorkflowTagMapping'; import type { TagEntity } from '@/databases/entities/TagEntity';
import { TagEntity } from '@/databases/entities/TagEntity';
import { ActiveWorkflowRunner } from '../../ActiveWorkflowRunner'; import { ActiveWorkflowRunner } from '../../ActiveWorkflowRunner';
import type { SourceControllPullOptions } from './types/sourceControlPullWorkFolder'; import type { SourceControllPullOptions } from './types/sourceControlPullWorkFolder';
import { In } from 'typeorm'; import { In } from 'typeorm';
@ -94,56 +92,54 @@ export class SourceControlImportService {
const ownerGlobalRole = await this.getOwnerGlobalRole(); const ownerGlobalRole = await this.getOwnerGlobalRole();
const encryptionKey = await UserSettings.getEncryptionKey(); const encryptionKey = await UserSettings.getEncryptionKey();
let importCredentialsResult: Array<{ id: string; name: string; type: string }> = []; let importCredentialsResult: Array<{ id: string; name: string; type: string }> = [];
await Db.transaction(async (transactionManager) => { importCredentialsResult = await Promise.all(
importCredentialsResult = await Promise.all( credentialFiles.map(async (file) => {
credentialFiles.map(async (file) => { LoggerProxy.debug(`Importing credentials file ${file}`);
LoggerProxy.debug(`Importing credentials file ${file}`); const credential = jsonParse<ExportableCredential>(
const credential = jsonParse<ExportableCredential>( await fsReadFile(file, { encoding: 'utf8' }),
await fsReadFile(file, { encoding: 'utf8' }), );
); const existingCredential = existingCredentials.find(
const existingCredential = existingCredentials.find( (e) => e.id === credential.id && e.type === credential.type,
(e) => e.id === credential.id && e.type === credential.type, );
); const sharedOwner = await Db.collections.SharedCredentials.findOne({
const sharedOwner = await Db.collections.SharedCredentials.findOne({ select: ['userId'],
select: ['userId'], where: {
where: { credentialsId: credential.id,
credentialsId: credential.id, roleId: In([ownerCredentialRole.id, ownerGlobalRole.id]),
roleId: In([ownerCredentialRole.id, ownerGlobalRole.id]), },
}, });
});
const { name, type, data, id, nodesAccess } = credential; const { name, type, data, id, nodesAccess } = credential;
const newCredentialObject = new Credentials({ id, name }, type, []); const newCredentialObject = new Credentials({ id, name }, type, []);
if (existingCredential?.data) { if (existingCredential?.data) {
newCredentialObject.data = existingCredential.data; newCredentialObject.data = existingCredential.data;
} else { } else {
newCredentialObject.setData(data, encryptionKey); newCredentialObject.setData(data, encryptionKey);
} }
newCredentialObject.nodesAccess = nodesAccess || existingCredential?.nodesAccess || []; newCredentialObject.nodesAccess = nodesAccess || existingCredential?.nodesAccess || [];
LoggerProxy.debug(`Updating credential id ${newCredentialObject.id as string}`); LoggerProxy.debug(`Updating credential id ${newCredentialObject.id as string}`);
await transactionManager.upsert(CredentialsEntity, newCredentialObject, ['id']); await Db.collections.Credentials.upsert(newCredentialObject, ['id']);
if (!sharedOwner) { if (!sharedOwner) {
const newSharedCredential = new SharedCredentials(); const newSharedCredential = new SharedCredentials();
newSharedCredential.credentialsId = newCredentialObject.id as string; newSharedCredential.credentialsId = newCredentialObject.id as string;
newSharedCredential.userId = userId; newSharedCredential.userId = userId;
newSharedCredential.roleId = ownerGlobalRole.id; newSharedCredential.roleId = ownerGlobalRole.id;
await transactionManager.upsert(SharedCredentials, { ...newSharedCredential }, [ await Db.collections.SharedCredentials.upsert({ ...newSharedCredential }, [
'credentialsId', 'credentialsId',
'userId', 'userId',
]); ]);
} }
return { return {
id: newCredentialObject.id as string, id: newCredentialObject.id as string,
name: newCredentialObject.name, name: newCredentialObject.name,
type: newCredentialObject.type, type: newCredentialObject.type,
}; };
}), }),
); );
});
return importCredentialsResult.filter((e) => e !== undefined); return importCredentialsResult.filter((e) => e !== undefined);
} }
@ -224,35 +220,31 @@ export class SourceControlImportService {
).map((e) => e.id), ).map((e) => e.id),
); );
await Db.transaction(async (transactionManager) => { await Promise.all(
await Promise.all( mappedTags.tags.map(async (tag) => {
mappedTags.tags.map(async (tag) => { await Db.collections.Tag.upsert(
await transactionManager.upsert( {
TagEntity, ...tag,
{ },
...tag, {
}, skipUpdateIfNoValuesChanged: true,
{ conflictPaths: { id: true },
skipUpdateIfNoValuesChanged: true, },
conflictPaths: { id: true }, );
}, }),
); );
}), await Promise.all(
); mappedTags.mappings.map(async (mapping) => {
await Promise.all( if (!existingWorkflowIds.has(String(mapping.workflowId))) return;
mappedTags.mappings.map(async (mapping) => { await Db.collections.WorkflowTagMapping.upsert(
if (!existingWorkflowIds.has(String(mapping.workflowId))) return; { tagId: String(mapping.tagId), workflowId: String(mapping.workflowId) },
await transactionManager.upsert( {
WorkflowTagMapping, skipUpdateIfNoValuesChanged: true,
{ tagId: String(mapping.tagId), workflowId: String(mapping.workflowId) }, conflictPaths: { tagId: true, workflowId: true },
{ },
skipUpdateIfNoValuesChanged: true, );
conflictPaths: { tagId: true, workflowId: true }, }),
}, );
);
}),
);
});
return mappedTags; return mappedTags;
} }
return { tags: [], mappings: [] }; return { tags: [], mappings: [] };
@ -273,74 +265,118 @@ export class SourceControlImportService {
const ownerWorkflowRole = await this.getOwnerWorkflowRole(); const ownerWorkflowRole = await this.getOwnerWorkflowRole();
const workflowRunner = Container.get(ActiveWorkflowRunner); const workflowRunner = Container.get(ActiveWorkflowRunner);
let importWorkflowsResult = new Array<{ id: string; name: string }>(); // read owner file if it exists and map workflow ids to owner emails
await Db.transaction(async (transactionManager) => { // then find existing users with those emails or fallback to passed in userId
importWorkflowsResult = await Promise.all( const ownerRecords: Record<string, string> = {};
workflowFiles.map(async (file) => { const ownersFile = await glob(SOURCE_CONTROL_OWNERS_EXPORT_FILE, {
LoggerProxy.debug(`Parsing workflow file ${file}`); cwd: this.gitFolder,
const importedWorkflow = jsonParse<IWorkflowToImport>( absolute: true,
await fsReadFile(file, { encoding: 'utf8' }), });
if (ownersFile.length > 0) {
LoggerProxy.debug(`Reading workflow owners from file ${ownersFile[0]}`);
const ownerEmails = jsonParse<Record<string, string>>(
await fsReadFile(ownersFile[0], { encoding: 'utf8' }),
{ fallbackValue: {} },
);
if (ownerEmails) {
const uniqueOwnerEmails = new Set(Object.values(ownerEmails));
const existingUsers = await Db.collections.User.find({
where: { email: In([...uniqueOwnerEmails]) },
});
Object.keys(ownerEmails).forEach((workflowId) => {
ownerRecords[workflowId] =
existingUsers.find((e) => e.email === ownerEmails[workflowId])?.id ?? userId;
});
}
}
let importWorkflowsResult = new Array<{ id: string; name: string } | undefined>();
const allSharedWorkflows = await Db.collections.SharedWorkflow.find({
select: ['workflowId', 'roleId', 'userId'],
});
importWorkflowsResult = await Promise.all(
workflowFiles.map(async (file) => {
LoggerProxy.debug(`Parsing workflow file ${file}`);
const importedWorkflow = jsonParse<IWorkflowToImport>(
await fsReadFile(file, { encoding: 'utf8' }),
);
if (!importedWorkflow?.id) {
return;
}
const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id);
if (existingWorkflow?.versionId === importedWorkflow.versionId) {
LoggerProxy.debug(
`Skipping import of workflow ${importedWorkflow.id ?? 'n/a'} - versionId is up to date`,
); );
const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id); return {
if (existingWorkflow?.versionId === importedWorkflow.versionId) { id: importedWorkflow.id ?? 'n/a',
LoggerProxy.debug( name: 'skipped',
`Skipping import of workflow ${ };
importedWorkflow.id ?? 'n/a' }
} - versionId is up to date`, LoggerProxy.debug(`Importing workflow ${importedWorkflow.id ?? 'n/a'}`);
); importedWorkflow.active = existingWorkflow?.active ?? false;
return { LoggerProxy.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`);
id: importedWorkflow.id ?? 'n/a', const upsertResult = await Db.collections.Workflow.upsert({ ...importedWorkflow }, ['id']);
name: 'skipped', if (upsertResult?.identifiers?.length !== 1) {
}; throw new Error(`Failed to upsert workflow ${importedWorkflow.id ?? 'new'}`);
} }
LoggerProxy.debug(`Importing workflow ${importedWorkflow.id ?? 'n/a'}`); // Update workflow owner to the user who exported the workflow, if that user exists
importedWorkflow.active = existingWorkflow?.active ?? false; // in the instance, and the workflow doesn't already have an owner
LoggerProxy.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`); const workflowOwnerId = ownerRecords[importedWorkflow.id] ?? userId;
const upsertResult = await transactionManager.upsert( const existingSharedWorkflowOwnerByRoleId = allSharedWorkflows.find(
WorkflowEntity, (e) => e.workflowId === importedWorkflow.id && e.roleId === ownerWorkflowRole.id,
{ ...importedWorkflow }, );
['id'], const existingSharedWorkflowOwnerByUserId = allSharedWorkflows.find(
); (e) => e.workflowId === importedWorkflow.id && e.userId === workflowOwnerId,
if (upsertResult?.identifiers?.length !== 1) { );
throw new Error(`Failed to upsert workflow ${importedWorkflow.id ?? 'new'}`); if (!existingSharedWorkflowOwnerByUserId && !existingSharedWorkflowOwnerByRoleId) {
} // no owner exists yet, so create one
// due to sequential Ids, this may have changed during the insert await Db.collections.SharedWorkflow.insert({
// TODO: once IDs are unique and we removed autoincrement, remove this workflowId: importedWorkflow.id,
const upsertedWorkflowId = upsertResult.identifiers[0].id as string; userId: workflowOwnerId,
await transactionManager.upsert( roleId: ownerWorkflowRole.id,
SharedWorkflow, });
} else if (existingSharedWorkflowOwnerByRoleId) {
// skip, because the workflow already has a global owner
} else if (existingSharedWorkflowOwnerByUserId && !existingSharedWorkflowOwnerByRoleId) {
// if the worklflow has a non-global owner that is referenced by the owner file,
// and no existing global owner, update the owner to the user referenced in the owner file
await Db.collections.SharedWorkflow.update(
{
workflowId: importedWorkflow.id,
userId: workflowOwnerId,
},
{ {
workflowId: upsertedWorkflowId,
userId,
roleId: ownerWorkflowRole.id, roleId: ownerWorkflowRole.id,
}, },
['workflowId', 'userId'],
); );
}
if (existingWorkflow?.active) { if (existingWorkflow?.active) {
try { try {
// remove active pre-import workflow // remove active pre-import workflow
LoggerProxy.debug(`Deactivating workflow id ${existingWorkflow.id}`); LoggerProxy.debug(`Deactivating workflow id ${existingWorkflow.id}`);
await workflowRunner.remove(existingWorkflow.id); await workflowRunner.remove(existingWorkflow.id);
// try activating the imported workflow // try activating the imported workflow
LoggerProxy.debug(`Reactivating workflow id ${existingWorkflow.id}`); LoggerProxy.debug(`Reactivating workflow id ${existingWorkflow.id}`);
await workflowRunner.add(existingWorkflow.id, 'activate'); await workflowRunner.add(existingWorkflow.id, 'activate');
} catch (error) { } catch (error) {
LoggerProxy.error( LoggerProxy.error(`Failed to activate workflow ${existingWorkflow.id}`, error as Error);
`Failed to activate workflow ${existingWorkflow.id}`,
error as Error,
);
}
} }
}
return { return {
id: importedWorkflow.id ?? 'unknown', id: importedWorkflow.id ?? 'unknown',
name: file, name: file,
}; };
}), }),
); );
});
return importWorkflowsResult; return importWorkflowsResult.filter((e) => e !== undefined) as Array<{
id: string;
name: string;
}>;
} }
async importFromWorkFolder(options: SourceControllPullOptions): Promise<ImportResult> { async importFromWorkFolder(options: SourceControllPullOptions): Promise<ImportResult> {

View file

@ -8,6 +8,5 @@ export interface ExportableWorkflow {
connections: IConnections; connections: IConnections;
settings?: IWorkflowSettings; settings?: IWorkflowSettings;
triggerCount: number; triggerCount: number;
owner: string;
versionId: string; versionId: string;
} }

View file

@ -16,4 +16,5 @@ export type SourceControlledFile = {
status: SourceControlledFileStatus; status: SourceControlledFileStatus;
location: SourceControlledFileLocation; location: SourceControlledFileLocation;
conflict: boolean; conflict: boolean;
updatedAt: string;
}; };

View file

@ -16,7 +16,10 @@ export default defineComponent({
theme: { theme: {
type: String, type: String,
default: 'default', default: 'default',
validator: (value: string) => ['default', 'primary', 'secondary', 'tertiary'].includes(value), validator: (value: string) =>
['default', 'success', 'warning', 'danger', 'primary', 'secondary', 'tertiary'].includes(
value,
),
}, },
size: { size: {
type: String, type: String,
@ -49,6 +52,27 @@ export default defineComponent({
border-color: var(--color-text-light); border-color: var(--color-text-light);
} }
.success {
composes: badge;
border-radius: var(--border-radius-base);
color: var(--color-success);
border-color: var(--color-success);
}
.warning {
composes: badge;
border-radius: var(--border-radius-base);
color: var(--color-warning);
border-color: var(--color-warning);
}
.danger {
composes: badge;
border-radius: var(--border-radius-base);
color: var(--color-danger);
border-color: var(--color-danger);
}
.primary { .primary {
composes: badge; composes: badge;
padding: var(--spacing-5xs) var(--spacing-3xs); padding: var(--spacing-5xs) var(--spacing-3xs);

View file

@ -1487,6 +1487,7 @@ export interface SourceControlAggregatedFile {
name: string; name: string;
status: string; status: string;
type: string; type: string;
updatedAt?: string;
} }
export declare namespace Cloud { export declare namespace Cloud {

View file

@ -1,13 +1,10 @@
import type { Cloud, IRestApiContext, InstanceUsage } from '@/Interface'; import type { Cloud, IRestApiContext, InstanceUsage } from '@/Interface';
import { get } from '@/utils'; import { get } from '@/utils';
export async function getCurrentPlan( export async function getCurrentPlan(context: IRestApiContext): Promise<Cloud.PlanData> {
context: IRestApiContext, return get(context.baseUrl, '/admin/cloud-plan');
cloudUserId: string,
): Promise<Cloud.PlanData> {
return get(context.baseUrl, `/user/${cloudUserId}/plan`);
} }
export async function getCurrentUsage(context: IRestApiContext): Promise<InstanceUsage> { export async function getCurrentUsage(context: IRestApiContext): Promise<InstanceUsage> {
return get(context.baseUrl, '/limits'); return get(context.baseUrl, '/cloud/limits');
} }

View file

@ -45,7 +45,7 @@
<div class="expression-editor ph-no-capture"> <div class="expression-editor ph-no-capture">
<ExpressionEditorModalInput <ExpressionEditorModalInput
:value="value" :value="value"
:isReadOnly="isReadOnly" :isReadOnly="isReadOnlyRoute"
:path="path" :path="path"
@change="valueChanged" @change="valueChanged"
@close="closeDialog" @close="closeDialog"

View file

@ -133,6 +133,7 @@ import {
MAX_WORKFLOW_NAME_LENGTH, MAX_WORKFLOW_NAME_LENGTH,
MODAL_CONFIRM, MODAL_CONFIRM,
PLACEHOLDER_EMPTY_WORKFLOW_ID, PLACEHOLDER_EMPTY_WORKFLOW_ID,
SOURCE_CONTROL_PUSH_MODAL_KEY,
VIEWS, VIEWS,
WORKFLOW_MENU_ACTIONS, WORKFLOW_MENU_ACTIONS,
WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY,
@ -151,7 +152,7 @@ import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
import type { IUser, IWorkflowDataUpdate, IWorkflowDb, IWorkflowToShare } from '@/Interface'; import type { IUser, IWorkflowDataUpdate, IWorkflowDb, IWorkflowToShare } from '@/Interface';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import { useTitleChange, useToast, useMessage } from '@/composables'; import { useTitleChange, useToast, useMessage, useLoadingService } from '@/composables';
import type { MessageBoxInputData } from 'element-ui/types/message-box'; import type { MessageBoxInputData } from 'element-ui/types/message-box';
import { import {
useUIStore, useUIStore,
@ -161,6 +162,7 @@ import {
useTagsStore, useTagsStore,
useUsersStore, useUsersStore,
useUsageStore, useUsageStore,
useSourceControlStore,
} from '@/stores'; } from '@/stores';
import type { IPermissions } from '@/permissions'; import type { IPermissions } from '@/permissions';
import { getWorkflowPermissions } from '@/permissions'; import { getWorkflowPermissions } from '@/permissions';
@ -197,7 +199,10 @@ export default defineComponent({
}, },
}, },
setup() { setup() {
const loadingService = useLoadingService();
return { return {
loadingService,
...useTitleChange(), ...useTitleChange(),
...useToast(), ...useToast(),
...useMessage(), ...useMessage(),
@ -211,6 +216,7 @@ export default defineComponent({
tagsEditBus: createEventBus(), tagsEditBus: createEventBus(),
MAX_WORKFLOW_NAME_LENGTH, MAX_WORKFLOW_NAME_LENGTH,
tagsSaving: false, tagsSaving: false,
eventBus: createEventBus(),
EnterpriseEditionFeature, EnterpriseEditionFeature,
}; };
}, },
@ -224,6 +230,7 @@ export default defineComponent({
useWorkflowsStore, useWorkflowsStore,
useUsersStore, useUsersStore,
useCloudPlanStore, useCloudPlanStore,
useSourceControlStore,
), ),
currentUser(): IUser | null { currentUser(): IUser | null {
return this.usersStore.currentUser; return this.usersStore.currentUser;
@ -305,6 +312,15 @@ export default defineComponent({
); );
} }
actions.push({
id: WORKFLOW_MENU_ACTIONS.PUSH,
label: this.$locale.baseText('menuActions.push'),
disabled:
!this.sourceControlStore.isEnterpriseSourceControlEnabled ||
!this.onWorkflowPage ||
this.onExecutionsTab,
});
actions.push({ actions.push({
id: WORKFLOW_MENU_ACTIONS.SETTINGS, id: WORKFLOW_MENU_ACTIONS.SETTINGS,
label: this.$locale.baseText('generic.settings'), label: this.$locale.baseText('generic.settings'),
@ -514,6 +530,30 @@ export default defineComponent({
(this.$refs.importFile as HTMLInputElement).click(); (this.$refs.importFile as HTMLInputElement).click();
break; break;
} }
case WORKFLOW_MENU_ACTIONS.PUSH: {
this.loadingService.startLoading();
try {
await this.onSaveButtonClick();
const status = await this.sourceControlStore.getAggregatedStatus();
const workflowStatus = status.filter(
(s) =>
(s.id === this.currentWorkflowId && s.type === 'workflow') || s.type !== 'workflow',
);
this.uiStore.openModalWithData({
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
data: { eventBus: this.eventBus, status: workflowStatus },
});
} catch (error) {
this.showError(error, this.$locale.baseText('error'));
} finally {
this.loadingService.stopLoading();
this.loadingService.setLoadingText(this.$locale.baseText('genericHelpers.loading'));
}
break;
}
case WORKFLOW_MENU_ACTIONS.SETTINGS: { case WORKFLOW_MENU_ACTIONS.SETTINGS: {
this.uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY); this.uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
break; break;

View file

@ -4,12 +4,16 @@ import { useRouter } from 'vue-router/composables';
import { createEventBus } from 'n8n-design-system/utils'; import { createEventBus } from 'n8n-design-system/utils';
import { useI18n, useLoadingService, useMessage, useToast } from '@/composables'; import { useI18n, useLoadingService, useMessage, useToast } from '@/composables';
import { useUIStore, useSourceControlStore } from '@/stores'; import { useUIStore, useSourceControlStore } from '@/stores';
import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants'; import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
const props = defineProps<{ const props = defineProps<{
isCollapsed: boolean; isCollapsed: boolean;
}>(); }>();
const responseStatuses = {
CONFLICT: 409,
};
const router = useRouter(); const router = useRouter();
const loadingService = useLoadingService(); const loadingService = useLoadingService();
const uiStore = useUIStore(); const uiStore = useUIStore();
@ -47,28 +51,17 @@ async function pushWorkfolder() {
async function pullWorkfolder() { async function pullWorkfolder() {
loadingService.startLoading(); loadingService.startLoading();
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull')); loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull'));
try { try {
await sourceControlStore.pullWorkfolder(false); await sourceControlStore.pullWorkfolder(false);
} catch (error) { } catch (error) {
const errorResponse = error.response; const errorResponse = error.response;
if (errorResponse?.status === 409) { if (errorResponse?.status === responseStatuses.CONFLICT) {
const confirm = await message.confirm( uiStore.openModalWithData({
i18n.baseText('settings.sourceControl.modals.pull.description'), name: SOURCE_CONTROL_PULL_MODAL_KEY,
i18n.baseText('settings.sourceControl.modals.pull.title'), data: { eventBus, status: errorResponse.data.data },
{ });
confirmButtonText: i18n.baseText('settings.sourceControl.modals.pull.buttons.save'),
cancelButtonText: i18n.baseText('settings.sourceControl.modals.pull.buttons.cancel'),
},
);
try {
if (confirm === 'confirm') {
await sourceControlStore.pullWorkfolder(true);
}
} catch (error) {
toast.showError(error, 'Error');
}
} else { } else {
toast.showError(error, 'Error'); toast.showError(error, 'Error');
} }

View file

@ -117,6 +117,12 @@
<SourceControlPushModal :modalName="modalName" :data="data" /> <SourceControlPushModal :modalName="modalName" :data="data" />
</template> </template>
</ModalRoot> </ModalRoot>
<ModalRoot :name="SOURCE_CONTROL_PULL_MODAL_KEY">
<template #default="{ modalName, data }">
<SourceControlPullModal :modalName="modalName" :data="data" />
</template>
</ModalRoot>
</div> </div>
</template> </template>
@ -146,6 +152,7 @@ import {
LOG_STREAM_MODAL_KEY, LOG_STREAM_MODAL_KEY,
ASK_AI_MODAL_KEY, ASK_AI_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
} from '@/constants'; } from '@/constants';
import AboutModal from './AboutModal.vue'; import AboutModal from './AboutModal.vue';
@ -172,6 +179,7 @@ import ImportCurlModal from './ImportCurlModal.vue';
import WorkflowShareModal from './WorkflowShareModal.ee.vue'; import WorkflowShareModal from './WorkflowShareModal.ee.vue';
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue'; import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue'; import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
import SourceControlPullModal from '@/components/SourceControlPullModal.ee.vue';
export default defineComponent({ export default defineComponent({
name: 'Modals', name: 'Modals',
@ -200,6 +208,7 @@ export default defineComponent({
ImportCurlModal, ImportCurlModal,
EventDestinationSettingsModal, EventDestinationSettingsModal,
SourceControlPushModal, SourceControlPushModal,
SourceControlPullModal,
}, },
data: () => ({ data: () => ({
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
@ -225,6 +234,7 @@ export default defineComponent({
IMPORT_CURL_MODAL_KEY, IMPORT_CURL_MODAL_KEY,
LOG_STREAM_MODAL_KEY, LOG_STREAM_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
}), }),
}); });
</script> </script>

View file

@ -15,7 +15,7 @@
color="text-dark" color="text-dark"
data-test-id="credentials-label" data-test-id="credentials-label"
> >
<div v-if="readonly || isReadOnly"> <div v-if="readonly || isReadOnlyRoute">
<n8n-input <n8n-input
:value="getSelectedName(credentialTypeDescription.name)" :value="getSelectedName(credentialTypeDescription.name)"
disabled disabled

View file

@ -367,7 +367,7 @@ import type {
EditorType, EditorType,
CodeNodeEditorLanguage, CodeNodeEditorLanguage,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow'; import { NodeHelpers, CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
import CredentialsSelect from '@/components/CredentialsSelect.vue'; import CredentialsSelect from '@/components/CredentialsSelect.vue';
import ExpressionEdit from '@/components/ExpressionEdit.vue'; import ExpressionEdit from '@/components/ExpressionEdit.vue';
@ -607,6 +607,11 @@ export default defineComponent({
return this.$locale.baseText('parameterInput.loadingOptions'); return this.$locale.baseText('parameterInput.loadingOptions');
} }
// if the value is marked as empty return empty string, to prevent displaying the asterisks
if (this.value === CREDENTIAL_EMPTY_VALUE) {
return '';
}
let returnValue; let returnValue;
if (this.isValueExpression === false) { if (this.isValueExpression === false) {
returnValue = this.isResourceLocatorParameter returnValue = this.isResourceLocatorParameter

View file

@ -7,7 +7,7 @@
:class="$style.pinnedDataCallout" :class="$style.pinnedDataCallout"
> >
{{ $locale.baseText('runData.pindata.thisDataIsPinned') }} {{ $locale.baseText('runData.pindata.thisDataIsPinned') }}
<span class="ml-4xs" v-if="!isReadOnly"> <span class="ml-4xs" v-if="!isReadOnlyRoute">
<n8n-link <n8n-link
theme="secondary" theme="secondary"
size="small" size="small"
@ -59,7 +59,7 @@
data-test-id="ndv-run-data-display-mode" data-test-id="ndv-run-data-display-mode"
/> />
<n8n-icon-button <n8n-icon-button
v-if="canPinData && !isReadOnly" v-if="canPinData && !isReadOnlyRoute"
v-show="!editMode.enabled" v-show="!editMode.enabled"
:title="$locale.baseText('runData.editOutput')" :title="$locale.baseText('runData.editOutput')"
:circle="false" :circle="false"
@ -99,7 +99,9 @@
type="tertiary" type="tertiary"
:active="hasPinData" :active="hasPinData"
icon="thumbtack" icon="thumbtack"
:disabled="editMode.enabled || (inputData.length === 0 && !hasPinData) || isReadOnly" :disabled="
editMode.enabled || (inputData.length === 0 && !hasPinData) || isReadOnlyRoute
"
@click="onTogglePinData({ source: 'pin-icon-click' })" @click="onTogglePinData({ source: 'pin-icon-click' })"
data-test-id="ndv-pin-data" data-test-id="ndv-pin-data"
/> />
@ -917,7 +919,7 @@ export default defineComponent({
if ( if (
value && value &&
value.length > 0 && value.length > 0 &&
!this.isReadOnly && !this.isReadOnlyRoute &&
!localStorage.getItem(LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG) !localStorage.getItem(LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG)
) { ) {
this.pinDataDiscoveryComplete(); this.pinDataDiscoveryComplete();

View file

@ -212,7 +212,7 @@ export default defineComponent({
copy_type: copyType, copy_type: copyType,
workflow_id: this.workflowsStore.workflowId, workflow_id: this.workflowsStore.workflowId,
pane: this.paneType, pane: this.paneType,
in_execution_log: this.isReadOnly, in_execution_log: this.isReadOnlyRoute,
}); });
this.copyToClipboard(value); this.copyToClipboard(value);

View file

@ -0,0 +1,89 @@
<script lang="ts" setup>
import Modal from './Modal.vue';
import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants';
import type { PropType } from 'vue';
import type { EventBus } from 'n8n-design-system/utils';
import type { SourceControlStatus } from '@/Interface';
import { useI18n, useLoadingService, useToast } from '@/composables';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores';
import { useRoute } from 'vue-router/composables';
const props = defineProps({
data: {
type: Object as PropType<{ eventBus: EventBus; status: SourceControlStatus }>,
default: () => ({}),
},
});
const defaultStagedFileTypes = ['tags', 'variables', 'credential'];
const loadingService = useLoadingService();
const uiStore = useUIStore();
const toast = useToast();
const { i18n } = useI18n();
const sourceControlStore = useSourceControlStore();
const route = useRoute();
function close() {
uiStore.closeModal(SOURCE_CONTROL_PULL_MODAL_KEY);
}
async function pullWorkfolder() {
loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.pull'));
close();
try {
await sourceControlStore.pullWorkfolder(true);
toast.showMessage({
message: i18n.baseText('settings.sourceControl.pull.success.description'),
title: i18n.baseText('settings.sourceControl.pull.success.title'),
type: 'success',
});
} catch (error) {
toast.showError(error, 'Error');
} finally {
loadingService.stopLoading();
}
}
</script>
<template>
<Modal
width="500px"
:title="i18n.baseText('settings.sourceControl.modals.pull.title')"
:eventBus="data.eventBus"
:name="SOURCE_CONTROL_PULL_MODAL_KEY"
>
<template #content>
<div :class="$style.container">
<n8n-text>
{{ i18n.baseText('settings.sourceControl.modals.pull.description') }}
</n8n-text>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<n8n-button type="tertiary" class="mr-2xs" @click="close">
{{ i18n.baseText('settings.sourceControl.modals.pull.buttons.cancel') }}
</n8n-button>
<n8n-button type="primary" @click="pullWorkfolder">
{{ i18n.baseText('settings.sourceControl.modals.pull.buttons.save') }}
</n8n-button>
</div>
</template>
</Modal>
</template>
<style module lang="scss">
.container > * {
overflow-wrap: break-word;
}
.footer {
display: flex;
flex-direction: row;
justify-content: flex-end;
}
</style>

View file

@ -9,6 +9,7 @@ import { useI18n, useLoadingService, useToast } from '@/composables';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores'; import { useUIStore } from '@/stores';
import { useRoute } from 'vue-router/composables'; import { useRoute } from 'vue-router/composables';
import dateformat from 'dateformat';
const props = defineProps({ const props = defineProps({
data: { data: {
@ -17,6 +18,8 @@ const props = defineProps({
}, },
}); });
const defaultStagedFileTypes = ['tags', 'variables', 'credential'];
const loadingService = useLoadingService(); const loadingService = useLoadingService();
const uiStore = useUIStore(); const uiStore = useUIStore();
const toast = useToast(); const toast = useToast();
@ -31,10 +34,71 @@ const commitMessage = ref('');
const loading = ref(true); const loading = ref(true);
const context = ref<'workflow' | 'workflows' | 'credentials' | string>(''); const context = ref<'workflow' | 'workflows' | 'credentials' | string>('');
const statusToBadgeThemeMap = {
created: 'success',
deleted: 'danger',
modified: 'warning',
renamed: 'warning',
};
const isSubmitDisabled = computed(() => { const isSubmitDisabled = computed(() => {
return !commitMessage.value || Object.values(staged.value).every((value) => !value); return !commitMessage.value || Object.values(staged.value).every((value) => !value);
}); });
const workflowId = computed(() => {
if (context.value === 'workflow') {
return route.params.name as string;
}
return '';
});
const sortedFiles = computed(() => {
const statusPriority = {
deleted: 1,
modified: 2,
renamed: 3,
created: 4,
};
return [...files.value].sort((a, b) => {
if (context.value === 'workflow') {
if (a.id === workflowId.value) {
return -1;
} else if (b.id === workflowId.value) {
return 1;
}
}
if (statusPriority[a.status] < statusPriority[b.status]) {
return -1;
} else if (statusPriority[a.status] > statusPriority[b.status]) {
return 1;
}
return a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0;
});
});
const selectAll = computed(() => {
return files.value.every((file) => staged.value[file.file]);
});
const workflowFiles = computed(() => {
return files.value.filter((file) => file.type === 'workflow');
});
const stagedWorkflowFiles = computed(() => {
return workflowFiles.value.filter((workflow) => staged.value[workflow.file]);
});
const selectAllIndeterminate = computed(() => {
return (
stagedWorkflowFiles.value.length > 0 &&
stagedWorkflowFiles.value.length < workflowFiles.value.length
);
});
onMounted(async () => { onMounted(async () => {
context.value = getContext(); context.value = getContext();
try { try {
@ -46,6 +110,22 @@ onMounted(async () => {
} }
}); });
function onToggleSelectAll() {
if (selectAll.value) {
files.value.forEach((file) => {
if (!defaultStagedFileTypes.includes(file.type)) {
staged.value[file.file] = false;
}
});
} else {
files.value.forEach((file) => {
if (!defaultStagedFileTypes.includes(file.type)) {
staged.value[file.file] = true;
}
});
}
}
function getContext() { function getContext() {
if (route.fullPath.startsWith('/workflows')) { if (route.fullPath.startsWith('/workflows')) {
return 'workflows'; return 'workflows';
@ -62,20 +142,24 @@ function getContext() {
} }
function getStagedFilesByContext(files: SourceControlAggregatedFile[]): Record<string, boolean> { function getStagedFilesByContext(files: SourceControlAggregatedFile[]): Record<string, boolean> {
const stagedFiles: SourceControlAggregatedFile[] = []; const stagedFiles = files.reduce((acc, file) => {
if (context.value === 'workflows') { acc[file.file] = false;
stagedFiles.push(...files.filter((file) => file.file.startsWith('workflows')));
} else if (context.value === 'credentials') {
stagedFiles.push(...files.filter((file) => file.file.startsWith('credentials')));
} else if (context.value === 'workflow') {
const workflowId = route.params.name as string;
stagedFiles.push(...files.filter((file) => file.type === 'workflow' && file.id === workflowId));
}
return stagedFiles.reduce<Record<string, boolean>>((acc, file) => {
acc[file.file] = true;
return acc; return acc;
}, {}); }, {});
files.forEach((file) => {
if (defaultStagedFileTypes.includes(file.type)) {
stagedFiles[file.file] = true;
}
if (context.value === 'workflow' && file.type === 'workflow' && file.id === workflowId.value) {
stagedFiles[file.file] = true;
} else if (context.value === 'workflows' && file.type === 'workflow') {
stagedFiles[file.file] = true;
}
});
return stagedFiles;
} }
function setStagedStatus(file: SourceControlAggregatedFile, status: boolean) { function setStagedStatus(file: SourceControlAggregatedFile, status: boolean) {
@ -89,6 +173,20 @@ function close() {
uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY); uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY);
} }
function renderUpdatedAt(file: SourceControlAggregatedFile) {
const currentYear = new Date().getFullYear();
return i18n.baseText('settings.sourceControl.lastUpdated', {
interpolate: {
date: dateformat(
file.updatedAt,
`d mmm${file.updatedAt.startsWith(currentYear) ? '' : ', yyyy'}`,
),
time: dateformat(file.updatedAt, 'HH:MM'),
},
});
}
async function commitAndPush() { async function commitAndPush() {
const fileNames = files.value.filter((file) => staged.value[file.file]).map((file) => file.file); const fileNames = files.value.filter((file) => staged.value[file.file]).map((file) => file.file);
@ -135,12 +233,24 @@ async function commitAndPush() {
</n8n-link> </n8n-link>
</n8n-text> </n8n-text>
<div v-if="files.length > 0"> <div v-if="workflowFiles.length > 0">
<n8n-text bold tag="p" class="mt-l mb-2xs"> <div class="mt-l mb-2xs">
{{ i18n.baseText('settings.sourceControl.modals.push.filesToCommit') }} <n8n-checkbox
</n8n-text> :indeterminate="selectAllIndeterminate"
:value="selectAll"
@input="onToggleSelectAll"
>
<n8n-text bold tag="strong">
{{ i18n.baseText('settings.sourceControl.modals.push.workflowsToCommit') }}
</n8n-text>
<n8n-text tag="strong" v-show="workflowFiles.length > 0">
({{ stagedWorkflowFiles.length }}/{{ workflowFiles.length }})
</n8n-text>
</n8n-checkbox>
</div>
<n8n-card <n8n-card
v-for="file in files" v-for="file in sortedFiles"
v-show="!defaultStagedFileTypes.includes(file.type)"
:key="file.file" :key="file.file"
:class="$style.listItem" :class="$style.listItem"
@click="setStagedStatus(file, !staged[file.file])" @click="setStagedStatus(file, !staged[file.file])"
@ -151,19 +261,34 @@ async function commitAndPush() {
:class="$style.listItemCheckbox" :class="$style.listItemCheckbox"
@input="setStagedStatus(file, !staged[file.file])" @input="setStagedStatus(file, !staged[file.file])"
/> />
<n8n-text bold> <div>
<span v-if="file.status === 'deleted'"> <n8n-text v-if="file.status === 'deleted'" color="text-light">
<span v-if="file.type === 'workflow'"> Workflow </span> <span v-if="file.type === 'workflow'"> Deleted Workflow: </span>
<span v-if="file.type === 'credential'"> Credential </span> <span v-if="file.type === 'credential'"> Deleted Credential: </span>
Id: {{ file.id }} <strong>{{ file.id }}</strong>
</span> </n8n-text>
<span v-else> <n8n-text bold v-else>
{{ file.name }} {{ file.name }}
</span> </n8n-text>
</n8n-text> <div v-if="file.updatedAt">
<n8n-badge :class="$style.listItemStatus"> <n8n-text color="text-light" size="small">
{{ file.status }} {{ renderUpdatedAt(file) }}
</n8n-badge> </n8n-text>
</div>
<div v-if="file.conflict">
<n8n-text color="danger" size="small">
{{ i18n.baseText('settings.sourceControl.modals.push.overrideVersionInGit') }}
</n8n-text>
</div>
</div>
<div :class="$style.listItemStatus">
<n8n-badge class="mr-2xs" v-if="workflowId === file.id && file.type === 'workflow'">
Current workflow
</n8n-badge>
<n8n-badge :theme="statusToBadgeThemeMap[file.status] || 'default'">
{{ file.status }}
</n8n-badge>
</div>
</div> </div>
</n8n-card> </n8n-card>
@ -228,22 +353,22 @@ async function commitAndPush() {
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
}
.listItemBody { .listItemBody {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
}
.listItemCheckbox { .listItemCheckbox {
display: inline-flex !important; display: inline-flex !important;
margin-bottom: 0 !important; margin-bottom: 0 !important;
margin-right: var(--spacing-2xs); margin-right: var(--spacing-2xs) !important;
} }
.listItemStatus { .listItemStatus {
margin-left: var(--spacing-2xs); margin-left: auto;
}
}
} }
.footer { .footer {

View file

@ -4,15 +4,16 @@ import userEvent from '@testing-library/user-event';
import { PiniaVuePlugin } from 'pinia'; import { PiniaVuePlugin } from 'pinia';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { merge } from 'lodash-es'; import { merge } from 'lodash-es';
import { STORES } from '@/constants'; import { SOURCE_CONTROL_PULL_MODAL_KEY, STORES } from '@/constants';
import { i18nInstance } from '@/plugins/i18n'; import { i18nInstance } from '@/plugins/i18n';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue'; import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
import { useUsersStore, useSourceControlStore } from '@/stores'; import { useUsersStore, useSourceControlStore, useUIStore } from '@/stores';
let pinia: ReturnType<typeof createTestingPinia>; let pinia: ReturnType<typeof createTestingPinia>;
let sourceControlStore: ReturnType<typeof useSourceControlStore>; let sourceControlStore: ReturnType<typeof useSourceControlStore>;
let usersStore: ReturnType<typeof useUsersStore>; let usersStore: ReturnType<typeof useUsersStore>;
let uiStore: ReturnType<typeof useUIStore>;
const renderComponent = (renderOptions: Parameters<typeof render>[1] = {}) => { const renderComponent = (renderOptions: Parameters<typeof render>[1] = {}) => {
return render( return render(
@ -42,6 +43,7 @@ describe('MainSidebarSourceControl', () => {
}); });
sourceControlStore = useSourceControlStore(); sourceControlStore = useSourceControlStore();
uiStore = useUIStore();
usersStore = useUsersStore(); usersStore = useUsersStore();
}); });
@ -66,7 +68,7 @@ describe('MainSidebarSourceControl', () => {
authorEmail: '', authorEmail: '',
repositoryUrl: '', repositoryUrl: '',
branchReadOnly: false, branchReadOnly: false,
branchColor: '#F4A6DC', branchColor: '#5296D6',
connected: true, connected: true,
publicKey: '', publicKey: '',
}); });
@ -89,13 +91,25 @@ describe('MainSidebarSourceControl', () => {
}); });
it('should show confirm if pull response http status code is 409', async () => { it('should show confirm if pull response http status code is 409', async () => {
const status = {};
vi.spyOn(sourceControlStore, 'pullWorkfolder').mockRejectedValueOnce({ vi.spyOn(sourceControlStore, 'pullWorkfolder').mockRejectedValueOnce({
response: { status: 409 }, response: { status: 409, data: { data: status } },
}); });
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
const { getAllByRole, getByRole } = renderComponent({ props: { isCollapsed: false } }); const { getAllByRole, getByRole } = renderComponent({ props: { isCollapsed: false } });
await userEvent.click(getAllByRole('button')[0]); await userEvent.click(getAllByRole('button')[0]);
await waitFor(() => expect(getByRole('dialog')).toBeInTheDocument()); await waitFor(() =>
expect(openModalSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: SOURCE_CONTROL_PULL_MODAL_KEY,
data: expect.objectContaining({
status,
}),
}),
),
);
}); });
}); });
}); });

View file

@ -49,6 +49,7 @@ export const IMPORT_CURL_MODAL_KEY = 'importCurl';
export const LOG_STREAM_MODAL_KEY = 'settingsLogStream'; export const LOG_STREAM_MODAL_KEY = 'settingsLogStream';
export const SOURCE_CONTROL_PUSH_MODAL_KEY = 'sourceControlPush'; export const SOURCE_CONTROL_PUSH_MODAL_KEY = 'sourceControlPush';
export const SOURCE_CONTROL_PULL_MODAL_KEY = 'sourceControlPull';
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = { export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
UNINSTALL: 'uninstall', UNINSTALL: 'uninstall',
@ -429,6 +430,7 @@ export const enum WORKFLOW_MENU_ACTIONS {
DOWNLOAD = 'download', DOWNLOAD = 'download',
IMPORT_FROM_URL = 'import-from-url', IMPORT_FROM_URL = 'import-from-url',
IMPORT_FROM_FILE = 'import-from-file', IMPORT_FROM_FILE = 'import-from-file',
PUSH = 'push',
SETTINGS = 'settings', SETTINGS = 'settings',
DELETE = 'delete', DELETE = 'delete',
} }
@ -516,7 +518,11 @@ export const N8N_CONTACT_EMAIL = 'contact@n8n.io';
export const EXPRESSION_EDITOR_PARSER_TIMEOUT = 15_000; // ms export const EXPRESSION_EDITOR_PARSER_TIMEOUT = 15_000; // ms
export const KEEP_AUTH_IN_NDV_FOR_NODES = [HTTP_REQUEST_NODE_TYPE, WEBHOOK_NODE_TYPE]; export const KEEP_AUTH_IN_NDV_FOR_NODES = [
HTTP_REQUEST_NODE_TYPE,
WEBHOOK_NODE_TYPE,
WAIT_NODE_TYPE,
];
export const MAIN_AUTH_FIELD_NAME = 'authentication'; export const MAIN_AUTH_FIELD_NAME = 'authentication';
export const NODE_RESOURCE_FIELD_NAME = 'resource'; export const NODE_RESOURCE_FIELD_NAME = 'resource';

View file

@ -17,7 +17,7 @@ export const genericHelpers = defineComponent({
}; };
}, },
computed: { computed: {
isReadOnly(): boolean { isReadOnlyRoute(): boolean {
return ![VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.LOG_STREAMING_SETTINGS].includes( return ![VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.LOG_STREAMING_SETTINGS].includes(
this.$route.name as VIEWS, this.$route.name as VIEWS,
); );
@ -50,7 +50,7 @@ export const genericHelpers = defineComponent({
return { date, time }; return { date, time };
}, },
editAllowedCheck(): boolean { editAllowedCheck(): boolean {
if (this.isReadOnly) { if (this.isReadOnlyRoute) {
this.showMessage({ this.showMessage({
// title: 'Workflow can not be changed!', // title: 'Workflow can not be changed!',
title: this.$locale.baseText('genericHelpers.showMessage.title'), title: this.$locale.baseText('genericHelpers.showMessage.title'),

View file

@ -43,6 +43,7 @@ import type {
import { externalHooks } from '@/mixins/externalHooks'; import { externalHooks } from '@/mixins/externalHooks';
import { nodeHelpers } from '@/mixins/nodeHelpers'; import { nodeHelpers } from '@/mixins/nodeHelpers';
import { genericHelpers } from '@/mixins/genericHelpers';
import { useToast, useMessage } from '@/composables'; import { useToast, useMessage } from '@/composables';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
@ -329,7 +330,7 @@ function executeData(
} }
export const workflowHelpers = defineComponent({ export const workflowHelpers = defineComponent({
mixins: [externalHooks, nodeHelpers], mixins: [externalHooks, nodeHelpers, genericHelpers],
setup() { setup() {
return { return {
...useToast(), ...useToast(),
@ -699,6 +700,7 @@ export const workflowHelpers = defineComponent({
forceSave = false, forceSave = false,
): Promise<boolean> { ): Promise<boolean> {
const currentWorkflow = id || this.$route.params.name; const currentWorkflow = id || this.$route.params.name;
const isLoading = this.loadingService !== null;
if (!currentWorkflow || ['new', PLACEHOLDER_EMPTY_WORKFLOW_ID].includes(currentWorkflow)) { if (!currentWorkflow || ['new', PLACEHOLDER_EMPTY_WORKFLOW_ID].includes(currentWorkflow)) {
return this.saveAsNewWorkflow({ name, tags }, redirect); return this.saveAsNewWorkflow({ name, tags }, redirect);
@ -706,6 +708,9 @@ export const workflowHelpers = defineComponent({
// Workflow exists already so update it // Workflow exists already so update it
try { try {
if (!forceSave && isLoading) {
return true;
}
this.uiStore.addActiveAction('workflowSaving'); this.uiStore.addActiveAction('workflowSaving');
const workflowDataRequest: IWorkflowDataUpdate = await this.getWorkflowDataToSave(); const workflowDataRequest: IWorkflowDataUpdate = await this.getWorkflowDataToSave();

View file

@ -622,6 +622,7 @@
"mainSidebar.executions": "All executions", "mainSidebar.executions": "All executions",
"menuActions.duplicate": "Duplicate", "menuActions.duplicate": "Duplicate",
"menuActions.download": "Download", "menuActions.download": "Download",
"menuActions.push": "Push to Git",
"menuActions.importFromUrl": "Import from URL...", "menuActions.importFromUrl": "Import from URL...",
"menuActions.importFromFile": "Import from File...", "menuActions.importFromFile": "Import from File...",
"menuActions.delete": "Delete", "menuActions.delete": "Delete",
@ -1320,14 +1321,14 @@
"settings.usageAndPlan.desktop.description": "Cloud plans allow you to collaborate with teammates. Plus you dont need to leave this app open all the time for your workflows to run.", "settings.usageAndPlan.desktop.description": "Cloud plans allow you to collaborate with teammates. Plus you dont need to leave this app open all the time for your workflows to run.",
"settings.sourceControl.title": "Source Control", "settings.sourceControl.title": "Source Control",
"settings.sourceControl.actionBox.title": "Available on Enterprise plan", "settings.sourceControl.actionBox.title": "Available on Enterprise plan",
"settings.sourceControl.actionBox.description": "Use Source Control to connect your instance to an external Git repository to backup and track changes made to your workflows, variables, and credentials. With Source Control you can also sync instances across multiple environments (development, production...).", "settings.sourceControl.actionBox.description": "Use multiple instances for different environments (dev, prod, etc.), deploying between them via a Git repository.",
"settings.sourceControl.actionBox.description.link": "More info",
"settings.sourceControl.actionBox.buttonText": "See plans", "settings.sourceControl.actionBox.buttonText": "See plans",
"settings.sourceControl.description": "Source Control allows you to connect your n8n instance to a Git branch of a repository. You can connect your branches to multiples n8n instances to create a multi environments setup. {link}", "settings.sourceControl.description": "Use multiple instances for different environments (dev, prod, etc.), deploying between them via a Git repository. {link}",
"settings.sourceControl.description.link": "Learn how to set up Source Control and Environments in n8n.", "settings.sourceControl.description.link": "More info",
"settings.sourceControl.gitConfig": "Git configuration", "settings.sourceControl.gitConfig": "Git configuration",
"settings.sourceControl.repoUrl": "Git repository URL (SSH)", "settings.sourceControl.repoUrl": "Git repository URL (SSH)",
"settings.sourceControl.repoUrlPlaceholder": "e.g. git@github.com:my-team/my-repository", "settings.sourceControl.repoUrlPlaceholder": "e.g. git@github.com:my-team/my-repository",
"settings.sourceControl.repoUrlDescription": "The SSH url of your Git repository",
"settings.sourceControl.repoUrlInvalid": "The Git repository URL is not valid", "settings.sourceControl.repoUrlInvalid": "The Git repository URL is not valid",
"settings.sourceControl.authorName": "Commit author name", "settings.sourceControl.authorName": "Commit author name",
"settings.sourceControl.authorEmail": "Commit author email", "settings.sourceControl.authorEmail": "Commit author email",
@ -1364,14 +1365,18 @@
"settings.sourceControl.modals.push.description.learnMore": "Learn more", "settings.sourceControl.modals.push.description.learnMore": "Learn more",
"settings.sourceControl.modals.push.description.learnMore.url": "https://docs.n8n.io/source-control/using/", "settings.sourceControl.modals.push.description.learnMore.url": "https://docs.n8n.io/source-control/using/",
"settings.sourceControl.modals.push.filesToCommit": "Files to commit", "settings.sourceControl.modals.push.filesToCommit": "Files to commit",
"settings.sourceControl.modals.push.workflowsToCommit": "Workflows to commit",
"settings.sourceControl.modals.push.everythingIsUpToDate": "Everything is up to date", "settings.sourceControl.modals.push.everythingIsUpToDate": "Everything is up to date",
"settings.sourceControl.modals.push.overrideVersionInGit": "This will override the version in Git",
"settings.sourceControl.modals.push.commitMessage": "Commit message", "settings.sourceControl.modals.push.commitMessage": "Commit message",
"settings.sourceControl.modals.push.commitMessage.placeholder": "e.g. My commit", "settings.sourceControl.modals.push.commitMessage.placeholder": "e.g. My commit",
"settings.sourceControl.modals.push.buttons.cancel": "Cancel", "settings.sourceControl.modals.push.buttons.cancel": "Cancel",
"settings.sourceControl.modals.push.buttons.save": "Commit and Push", "settings.sourceControl.modals.push.buttons.save": "Commit and Push",
"settings.sourceControl.modals.push.success.title": "Pushed successfully", "settings.sourceControl.modals.push.success.title": "Pushed successfully",
"settings.sourceControl.modals.push.success.description": "The files you selected were committed and pushed to the remote repository", "settings.sourceControl.modals.push.success.description": "The files you selected were committed and pushed to the remote repository",
"settings.sourceControl.modals.pull.title": "Override local changes", "settings.sourceControl.pull.success.title": "Pulled successfully",
"settings.sourceControl.pull.success.description": "Make sure you fill out the details of any new credentials or variables",
"settings.sourceControl.modals.pull.title": "Override local changes?",
"settings.sourceControl.modals.pull.description": "Some remote changes are going to override some of your local changes. Are you sure you want to continue?", "settings.sourceControl.modals.pull.description": "Some remote changes are going to override some of your local changes. Are you sure you want to continue?",
"settings.sourceControl.modals.pull.buttons.cancel": "@:_reusableBaseText.cancel", "settings.sourceControl.modals.pull.buttons.cancel": "@:_reusableBaseText.cancel",
"settings.sourceControl.modals.pull.buttons.save": "Pull and override", "settings.sourceControl.modals.pull.buttons.save": "Pull and override",
@ -1391,6 +1396,7 @@
"settings.sourceControl.toast.disconnected.error": "Error disconnecting from Git", "settings.sourceControl.toast.disconnected.error": "Error disconnecting from Git",
"settings.sourceControl.loading.pull": "Pulling from remote", "settings.sourceControl.loading.pull": "Pulling from remote",
"settings.sourceControl.loading.push": "Pushing to remote", "settings.sourceControl.loading.push": "Pushing to remote",
"settings.sourceControl.lastUpdated": "Last updated {date} at {time}",
"settings.sourceControl.saved.title": "Settings successfully saved", "settings.sourceControl.saved.title": "Settings successfully saved",
"settings.sourceControl.refreshBranches.tooltip": "Reload branches list", "settings.sourceControl.refreshBranches.tooltip": "Reload branches list",
"settings.sourceControl.refreshBranches.success": "Branches successfully refreshed", "settings.sourceControl.refreshBranches.success": "Branches successfully refreshed",

View file

@ -53,7 +53,7 @@ export const useCloudPlanStore = defineStore('cloudPlan', () => {
state.loadingPlan = true; state.loadingPlan = true;
let plan; let plan;
try { try {
plan = await getCurrentPlan(rootStore.getRestCloudApiContext, `${cloudUserId}`); plan = await getCurrentPlan(rootStore.getRestApiContext);
state.data = plan; state.data = plan;
state.loadingPlan = false; state.loadingPlan = false;
} catch (error) { } catch (error) {

View file

@ -1,27 +1,34 @@
import { computed, reactive } from 'vue'; import { computed, reactive } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { EnterpriseEditionFeature } from '@/constants'; import { EnterpriseEditionFeature } from '@/constants';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore, useRootStore, useUsersStore } from '@/stores';
import * as vcApi from '@/api/sourceControl'; import * as vcApi from '@/api/sourceControl';
import { useRootStore } from '@/stores/n8nRoot.store';
import type { SourceControlPreferences } from '@/Interface'; import type { SourceControlPreferences } from '@/Interface';
export const useSourceControlStore = defineStore('sourceControl', () => { export const useSourceControlStore = defineStore('sourceControl', () => {
const rootStore = useRootStore(); const rootStore = useRootStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const usersStore = useUsersStore();
const isEnterpriseSourceControlEnabled = computed(() => const isEnterpriseSourceControlEnabled = computed(() =>
settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.SourceControl), settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.SourceControl),
); );
const defaultAuthor = computed(() => {
const user = usersStore.currentUser;
return {
name: user?.fullName ?? `${user?.firstName} ${user?.lastName}`.trim(),
email: user?.email ?? '',
};
});
const preferences = reactive<SourceControlPreferences>({ const preferences = reactive<SourceControlPreferences>({
branchName: '', branchName: '',
branches: [], branches: [],
authorName: '', authorName: defaultAuthor.value.name,
authorEmail: '', authorEmail: defaultAuthor.value.email,
repositoryUrl: '', repositoryUrl: '',
branchReadOnly: false, branchReadOnly: false,
branchColor: '#F4A6DC', branchColor: '#5296D6',
connected: false, connected: false,
publicKey: '', publicKey: '',
}); });

View file

@ -31,6 +31,7 @@ import {
WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY, WORKFLOW_SHARE_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
} from '@/constants'; } from '@/constants';
import type { import type {
CurlToJSONResponse, CurlToJSONResponse,
@ -137,6 +138,9 @@ export const useUIStore = defineStore(STORES.UI, {
[SOURCE_CONTROL_PUSH_MODAL_KEY]: { [SOURCE_CONTROL_PUSH_MODAL_KEY]: {
open: false, open: false,
}, },
[SOURCE_CONTROL_PULL_MODAL_KEY]: {
open: false,
},
}, },
modalStack: [], modalStack: [],
sidebarMenuCollapsed: true, sidebarMenuCollapsed: true,

View file

@ -54,7 +54,7 @@
@run="onNodeRun" @run="onNodeRun"
:key="`${nodeData.id}_node`" :key="`${nodeData.id}_node`"
:name="nodeData.name" :name="nodeData.name"
:isReadOnly="isReadOnly || readOnlyEnv" :isReadOnly="isReadOnlyRoute || readOnlyEnv"
:instance="instance" :instance="instance"
:isActive="!!activeNode && activeNode.name === nodeData.name" :isActive="!!activeNode && activeNode.name === nodeData.name"
:hideActions="pullConnActive" :hideActions="pullConnActive"
@ -76,7 +76,7 @@
@removeNode="(name) => removeNode(name, true)" @removeNode="(name) => removeNode(name, true)"
:key="`${nodeData.id}_sticky`" :key="`${nodeData.id}_sticky`"
:name="nodeData.name" :name="nodeData.name"
:isReadOnly="isReadOnly || readOnlyEnv" :isReadOnly="isReadOnlyRoute || readOnlyEnv"
:instance="instance" :instance="instance"
:isActive="!!activeNode && activeNode.name === nodeData.name" :isActive="!!activeNode && activeNode.name === nodeData.name"
:nodeViewScale="nodeViewScale" :nodeViewScale="nodeViewScale"
@ -87,7 +87,7 @@
</div> </div>
</div> </div>
<node-details-view <node-details-view
:readOnly="isReadOnly || readOnlyEnv" :readOnly="isReadOnlyRoute || readOnlyEnv"
:renaming="renamingActive" :renaming="renamingActive"
:isProductionExecutionPreview="isProductionExecutionPreview" :isProductionExecutionPreview="isProductionExecutionPreview"
@valueChanged="valueChanged" @valueChanged="valueChanged"
@ -95,14 +95,14 @@
@saveKeyboardShortcut="onSaveKeyboardShortcut" @saveKeyboardShortcut="onSaveKeyboardShortcut"
/> />
<node-creation <node-creation
v-if="!isReadOnly && !readOnlyEnv" v-if="!isReadOnlyRoute && !readOnlyEnv"
:create-node-active="createNodeActive" :create-node-active="createNodeActive"
:node-view-scale="nodeViewScale" :node-view-scale="nodeViewScale"
@toggleNodeCreator="onToggleNodeCreator" @toggleNodeCreator="onToggleNodeCreator"
@addNode="onAddNode" @addNode="onAddNode"
/> />
<canvas-controls /> <canvas-controls />
<div class="workflow-execute-wrapper" v-if="!isReadOnly"> <div class="workflow-execute-wrapper" v-if="!isReadOnlyRoute">
<span <span
@mouseenter="showTriggerMissingToltip(true)" @mouseenter="showTriggerMissingToltip(true)"
@mouseleave="showTriggerMissingToltip(false)" @mouseleave="showTriggerMissingToltip(false)"
@ -149,7 +149,7 @@
/> />
<n8n-icon-button <n8n-icon-button
v-if="!isReadOnly && workflowExecution && !workflowRunning && !allTriggersDisabled" v-if="!isReadOnlyRoute && workflowExecution && !workflowRunning && !allTriggersDisabled"
:title="$locale.baseText('nodeView.deletesTheCurrentExecutionData')" :title="$locale.baseText('nodeView.deletesTheCurrentExecutionData')"
icon="trash" icon="trash"
size="large" size="large"
@ -961,7 +961,7 @@ export default defineComponent({
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (this.isReadOnly) { if (this.isReadOnlyRoute) {
return; return;
} }
@ -1015,13 +1015,13 @@ export default defineComponent({
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
this.onToggleNodeCreator({ this.onToggleNodeCreator({
source: NODE_CREATOR_OPEN_SOURCES.TAB, source: NODE_CREATOR_OPEN_SOURCES.TAB,
createNodeActive: !this.createNodeActive && !this.isReadOnly, createNodeActive: !this.createNodeActive && !this.isReadOnlyRoute,
}); });
} else if (e.key === this.controlKeyCode) { } else if (e.key === this.controlKeyCode) {
this.ctrlKeyPressed = true; this.ctrlKeyPressed = true;
} else if (e.key === ' ') { } else if (e.key === ' ') {
this.moveCanvasKeyPressed = true; this.moveCanvasKeyPressed = true;
} else if (e.key === 'F2' && !this.isReadOnly) { } else if (e.key === 'F2' && !this.isReadOnlyRoute) {
const lastSelectedNode = this.lastSelectedNode; const lastSelectedNode = this.lastSelectedNode;
if (lastSelectedNode !== null && lastSelectedNode.type !== STICKY_NODE_TYPE) { if (lastSelectedNode !== null && lastSelectedNode.type !== STICKY_NODE_TYPE) {
void this.callDebounced( void this.callDebounced(
@ -1067,7 +1067,7 @@ export default defineComponent({
const lastSelectedNode = this.lastSelectedNode; const lastSelectedNode = this.lastSelectedNode;
if (lastSelectedNode !== null) { if (lastSelectedNode !== null) {
if (lastSelectedNode.type === STICKY_NODE_TYPE && this.isReadOnly) { if (lastSelectedNode.type === STICKY_NODE_TYPE && this.isReadOnlyRoute) {
return; return;
} }
this.ndvStore.activeNodeName = lastSelectedNode.name; this.ndvStore.activeNodeName = lastSelectedNode.name;
@ -1307,7 +1307,7 @@ export default defineComponent({
}, },
cutSelectedNodes() { cutSelectedNodes() {
const deleteCopiedNodes = !this.isReadOnly; const deleteCopiedNodes = !this.isReadOnlyRoute;
this.copySelectedNodes(deleteCopiedNodes); this.copySelectedNodes(deleteCopiedNodes);
if (deleteCopiedNodes) { if (deleteCopiedNodes) {
this.deleteSelectedNodes(); this.deleteSelectedNodes();
@ -2162,7 +2162,7 @@ export default defineComponent({
if (!this.suspendRecordingDetachedConnections) { if (!this.suspendRecordingDetachedConnections) {
this.historyStore.pushCommandToUndo(new AddConnectionCommand(connectionData)); this.historyStore.pushCommandToUndo(new AddConnectionCommand(connectionData));
} }
if (!this.isReadOnly) { if (!this.isReadOnlyRoute) {
NodeViewUtils.addConnectionActionsOverlay( NodeViewUtils.addConnectionActionsOverlay(
info.connection, info.connection,
() => { () => {
@ -2211,7 +2211,7 @@ export default defineComponent({
} }
if ( if (
this.isReadOnly || this.isReadOnlyRoute ||
this.readOnlyEnv || this.readOnlyEnv ||
this.enterTimer || this.enterTimer ||
!connection || !connection ||
@ -2242,7 +2242,7 @@ export default defineComponent({
} }
if ( if (
this.isReadOnly || this.isReadOnlyRoute ||
this.readOnlyEnv || this.readOnlyEnv ||
!connection || !connection ||
this.activeConnection?.id !== connection.id this.activeConnection?.id !== connection.id
@ -2609,7 +2609,7 @@ export default defineComponent({
// Create connections in DOM // Create connections in DOM
this.instance?.connect({ this.instance?.connect({
uuids: uuid, uuids: uuid,
detachable: !this.isReadOnly, detachable: !this.isReadOnlyRoute,
}); });
setTimeout(() => { setTimeout(() => {

View file

@ -226,7 +226,6 @@ const refreshBranches = async () => {
>{{ locale.baseText('settings.sourceControl.button.disconnect') }}</n8n-button >{{ locale.baseText('settings.sourceControl.button.disconnect') }}</n8n-button
> >
</div> </div>
<small>{{ locale.baseText('settings.sourceControl.repoUrlDescription') }}</small>
</div> </div>
<div :class="[$style.group, $style.groupFlex]"> <div :class="[$style.group, $style.groupFlex]">
<div> <div>
@ -380,6 +379,12 @@ const refreshBranches = async () => {
<template #heading> <template #heading>
<span>{{ locale.baseText('settings.sourceControl.actionBox.title') }}</span> <span>{{ locale.baseText('settings.sourceControl.actionBox.title') }}</span>
</template> </template>
<template #description>
{{ locale.baseText('settings.sourceControl.actionBox.description') }}
<a :href="locale.baseText('settings.sourceControl.docs.url')" target="_blank">
{{ locale.baseText('settings.sourceControl.actionBox.description.link') }}
</a>
</template>
</n8n-action-box> </n8n-action-box>
</div> </div>
</template> </template>

View file

@ -48,13 +48,13 @@ all the basics.
A n8n node is a JavaScript file (normally written in TypeScript) which describes A n8n node is a JavaScript file (normally written in TypeScript) which describes
some basic information (like name, description, ...) and also at least one method. some basic information (like name, description, ...) and also at least one method.
Depending on which method got implemented defines if it is a a regular-, trigger- Depending on which method gets implemented defines if it is a regular-, trigger-
or webhook-node. or webhook-node.
A simple regular node which: A simple regular node which:
- defines one node property - defines one node property
- sets its value do all items it receives - sets its value to all items it receives
would look like this: would look like this:
@ -121,33 +121,33 @@ export class MyNode implements INodeType {
``` ```
The "description" property has to be set on all nodes because it contains all The "description" property has to be set on all nodes because it contains all
the base information. Additionally do all nodes have to have exactly one of the the base information. Additionally all nodes have to have exactly one of the
following methods defined which contains the actual logic: following methods defined which contains the actual logic:
**Regular node** **Regular node**
Method get called when the workflow gets executed Method is called when the workflow gets executed
- `execute`: Executed once no matter how many items - `execute`: Executed once no matter how many items
By default always `execute` should be used especially when creating a By default, `execute` should always be used, especially when creating a
third-party integration. The reason for that is that it is way more flexible third-party integration. The reason for this is that it provides much more
and allows to, for example, return a different amount of items than it received flexibility and allows, for example, returning a different number of items than
as input. This is very important when a node should query data like _return it received as input. This becomes crucial when a node needs to query data such as _return
all users_. In that case, does the node normally just receive one input-item all users_. In such cases, the node typically receives only one input item but returns as
but returns as many as users exist. So in doubt always `execute` should be many items as there are users. Therefore, when in doubt, it is recommended to use `execute`!
used!
**Trigger node** **Trigger node**
Method gets called once when the workflow gets activated. It can then trigger Method is called once when the workflow gets activated. It can then trigger workflow runs and provide the necessary data by itself.
workflow runs which data it provides by itself.
- `trigger` - `trigger`
**Webhook node** **Webhook node**
Method gets called when webhook gets called. Method is called when webhook gets called.
- `webhook` - `webhook`
@ -156,11 +156,11 @@ Method gets called when webhook gets called.
Property overview Property overview
- **description** [required]: Describes the node like its name, properties, hooks, ... see `Node Type Description` bellow. - **description** [required]: Describes the node like its name, properties, hooks, ... see `Node Type Description` bellow.
- **execute** [optional]: Method get called when the workflow gets executed (once). - **execute** [optional]: Method is called when the workflow gets executed (once).
- **hooks** [optional]: The hook methods. - **hooks** [optional]: The hook methods.
- **methods** [optional]: Additional methods. Currently only "loadOptions" exists which allows loading options for parameters from external services - **methods** [optional]: Additional methods. Currently only "loadOptions" exists which allows loading options for parameters from external services
- **trigger** [optional]: Method gets called once when the workflow gets activated. - **trigger** [optional]: Method is called once when the workflow gets activated.
- **webhook** [optional]: Method gets called when webhook gets called. - **webhook** [optional]: Method is called when webhook gets called.
- **webhookMethods** [optional]: Methods to setup webhooks on external services. - **webhookMethods** [optional]: Methods to setup webhooks on external services.
### Node Type Description ### Node Type Description
@ -173,15 +173,15 @@ The following properties can be set in the node description:
- **description** [required]: Description to display users in Editor UI - **description** [required]: Description to display users in Editor UI
- **group** [required]: Node group for example "transform" or "trigger" - **group** [required]: Node group for example "transform" or "trigger"
- **hooks** [optional]: Methods to execute at different points in time like when the workflow gets activated or deactivated - **hooks** [optional]: Methods to execute at different points in time like when the workflow gets activated or deactivated
- **icon** [optional]: Icon to display (can be an icon or a font awsome icon) - **icon** [optional]: Icon to display (can be an icon or a font awesome icon)
- **inputs** [required]: Types of inputs the node has (currently only "main" exists) and the amount - **inputs** [required]: Types of inputs the node has (currently only "main" exists) and the amount
- **outputs** [required]: Types of outputs the node has (currently only "main" exists) and the amount - **outputs** [required]: Types of outputs the node has (currently only "main" exists) and the amount
- **outputNames** [optional]: In case a node has multiple outputs names can be set that users know what data to expect - **outputNames** [optional]: In case a node has multiple outputs, names can be set that users know what data to expect
- **maxNodes** [optional]: If not an unlimited amount of nodes of that type can exist in a workflow the max-amount can be specified - **maxNodes** [optional]: If an unlimited number of nodes of that type cannot exist in a workflow, the max-amount can be specified
- **name** [required]: Name of the node (for n8n to use internally, in camelCase) - **name** [required]: Name of the node (for n8n to use internally, in camelCase)
- **properties** [required]: Properties which get displayed in the Editor UI and can be set by the user - **properties** [required]: Properties which get displayed in the Editor UI and can be set by the user
- **subtitle** [optional]: Text which should be displayed underneath the name of the node in the Editor UI (can be an expression) - **subtitle** [optional]: Text which should be displayed underneath the name of the node in the Editor UI (can be an expression)
- **version** [required]: Version of the node. Currently always "1" (integer). For future usage, does not get used yet. - **version** [required]: Version of the node. Currently always "1" (integer). For future usage, does not get used yet
- **webhooks** [optional]: Webhooks the node should listen to - **webhooks** [optional]: Webhooks the node should listen to
### Node Properties ### Node Properties
@ -201,18 +201,18 @@ The following properties can be set in the node properties:
### Node Property Options ### Node Property Options
The following properties can be set in the node property options. The following properties can be set in the node property options:
All properties are optional. However, most only work when the node-property is of a specfic type. All properties are optional. However, most only work when the node-property is of a specfic type.
- **alwaysOpenEditWindow** [type: json]: If set then the "Editor Window" will always open when the user tries to edit the field. Helpful if long text is typically used in the property. - **alwaysOpenEditWindow** [type: json]: If set then the "Editor Window" will always open when the user tries to edit the field. Helpful if long text is typically used in the property
- **loadOptionsMethod** [type: options]: Method to use to load options from an external service - **loadOptionsMethod** [type: options]: Method to use to load options from an external service
- **maxValue** [type: number]: Maximum value of the number - **maxValue** [type: number]: Maximum value of the number
- **minValue** [type: number]: Minimum value of the number - **minValue** [type: number]: Minimum value of the number
- **multipleValues** [type: all]: If set the property gets turned into an Array and the user can add multiple values - **multipleValues** [type: all]: If set the property gets turned into an Array and the user can add multiple values
- **multipleValueButtonText** [type: all]: Custom text for add button in case "multipleValues" got set - **multipleValueButtonText** [type: all]: Custom text for add button in case "multipleValues" were set
- **numberPrecision** [type: number]: The precision of the number. By default it is "0" and will so only allow integers. - **numberPrecision** [type: number]: The precision of the number. By default, it is "0" and will only allow integers
- **password** [type: string]: If a password field should be displayed (normally only used by credentials because all node data is not encrypted and get saved in clear-text) - **password** [type: string]: If a password field should be displayed (normally only used by credentials because all node data is not encrypted and gets saved in clear-text)
- **rows** [type: string]: Number of rows the input field should have. By default it is "1" - **rows** [type: string]: Number of rows the input field should have. By default it is "1"
## License ## License

View file

@ -5,12 +5,13 @@ import type {
INodeProperties, INodeProperties,
} from 'n8n-workflow'; } from 'n8n-workflow';
export class SendInBlueApi implements ICredentialType { export class BrevoApi implements ICredentialType {
// keep sendinblue name for backward compatibility
name = 'sendInBlueApi'; name = 'sendInBlueApi';
displayName = 'SendInBlue'; displayName = 'Brevo';
documentationUrl = 'sendinblue'; documentationUrl = 'brevo';
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{ {
@ -33,7 +34,7 @@ export class SendInBlueApi implements ICredentialType {
test: ICredentialTestRequest = { test: ICredentialTestRequest = {
request: { request: {
baseURL: 'https://api.sendinblue.com/v3', baseURL: 'https://api.brevo.com/v3',
url: '/account', url: '/account',
}, },
}; };

View file

@ -0,0 +1,72 @@
import type {
IAuthenticateGeneric,
ICredentialTestRequest,
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class CrowdDevApi implements ICredentialType {
name = 'crowdDevApi';
displayName = 'crowd.dev API';
documentationUrl = 'crowdDev';
properties: INodeProperties[] = [
{
displayName: 'URL',
name: 'url',
type: 'string',
default: 'https://app.crowd.dev',
},
{
displayName: 'Tenant ID',
name: 'tenantId',
type: 'string',
default: '',
},
{
displayName: 'Token',
name: 'token',
type: 'string',
typeOptions: {
password: true,
},
default: '',
},
{
displayName: 'Ignore SSL Issues',
name: 'allowUnauthorizedCerts',
type: 'boolean',
description: 'Whether to connect even if SSL certificate validation is not possible',
default: false,
},
];
// This allows the credential to be used by other parts of n8n
// stating how this credential is injected as part of the request
// An example is the Http Request node that can make generic calls
// reusing this credential
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
Authorization: '={{"Bearer " + $credentials.token}}',
},
},
};
// The block below tells how this credential can be tested
test: ICredentialTestRequest = {
request: {
method: 'POST',
baseURL: '={{$credentials.url.replace(/\\/$/, "") + "/api/tenant/" + $credentials.tenantId}}',
url: '/member/query',
skipSslCertificateValidation: '={{ $credentials.allowUnauthorizedCerts }}',
body: {
limit: 1,
offset: 0,
},
},
};
}

View file

@ -22,5 +22,12 @@ export class GoogleDriveOAuth2Api implements ICredentialType {
type: 'hidden', type: 'hidden',
default: scopes.join(' '), default: scopes.join(' '),
}, },
{
displayName:
'Make sure that you have enabled the Google Drive API in the Google Cloud Console. <a href="https://docs.n8n.io/integrations/builtin/credentials/google/oauth-generic/#scopes" target="_blank">More info</a>.',
name: 'notice',
type: 'notice',
default: '',
},
]; ];
} }

View file

@ -0,0 +1,28 @@
/* eslint-disable n8n-nodes-base/cred-class-field-name-unsuffixed */
/* eslint-disable n8n-nodes-base/cred-class-name-unsuffixed */
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class HttpCustomAuth implements ICredentialType {
name = 'httpCustomAuth';
displayName = 'Custom Auth';
documentationUrl = 'httpRequest';
genericAuth = true;
icon = 'node:n8n-nodes-base.httpRequest';
properties: INodeProperties[] = [
{
displayName: 'JSON',
name: 'json',
type: 'json',
required: true,
description: 'Use json to specify authentication values for headers, body and qs.',
placeholder:
'{ "headers": { "key" : "value" }, "body": { "key": "value" }, "qs": { "key": "value" } }',
default: '',
},
];
}

View file

@ -34,5 +34,12 @@ export class TwitterOAuth1Api implements ICredentialType {
type: 'hidden', type: 'hidden',
default: 'HMAC-SHA1', default: 'HMAC-SHA1',
}, },
{
displayName:
'Some operations requires a Basic or a Pro API for more informations see <a href="https://developer.twitter.com/en/products/twitter-api" target="_blank">Twitter Api Doc</a>',
name: 'apiPermissioms',
type: 'notice',
default: '',
},
]; ];
} }

View file

@ -0,0 +1,73 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
const scopes = [
'tweet.read',
'users.read',
'tweet.write',
'tweet.moderate.write',
'users.read',
'follows.read',
'follows.write',
'offline.access',
'like.read',
'like.write',
'dm.write',
'dm.read',
'list.read',
'list.write',
];
export class TwitterOAuth2Api implements ICredentialType {
name = 'twitterOAuth2Api';
extends = ['oAuth2Api'];
displayName = 'Twitter OAuth2 API';
documentationUrl = 'twitter';
properties: INodeProperties[] = [
{
displayName:
'Some operations requires a Basic or a Pro API for more informations see <a href="https://developer.twitter.com/en/products/twitter-api" target="_blank">Twitter Api Doc</a>',
name: 'apiPermissioms',
type: 'notice',
default: '',
},
{
displayName: 'Grant Type',
name: 'grantType',
type: 'hidden',
default: 'pkce',
},
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden',
default: 'https://twitter.com/i/oauth2/authorize',
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden',
default: 'https://api.twitter.com/2/oauth2/token',
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden',
default: `${scopes.join(' ')}`,
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden',
default: '',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden',
default: 'header',
},
];
}

View file

@ -4,7 +4,7 @@ import type {
INodeProperties, INodeProperties,
JsonObject, JsonObject,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { SendInBlueNode } from './GenericFunctions'; import { BrevoNode } from './GenericFunctions';
export const attributeOperations: INodeProperties[] = [ export const attributeOperations: INodeProperties[] = [
{ {
@ -43,7 +43,7 @@ export const attributeOperations: INodeProperties[] = [
requestOptions: IHttpRequestOptions, requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> { ): Promise<IHttpRequestOptions> {
const selectedCategory = this.getNodeParameter('attributeCategory') as string; const selectedCategory = this.getNodeParameter('attributeCategory') as string;
const override = SendInBlueNode.INTERCEPTORS.get(selectedCategory); const override = BrevoNode.INTERCEPTORS.get(selectedCategory);
if (override) { if (override) {
override.call(this, requestOptions.body! as JsonObject); override.call(this, requestOptions.body! as JsonObject);
} }

View file

@ -1,18 +1,19 @@
{ {
"node": "n8n-nodes-base.sendInBlue", "node": "n8n-nodes-base.brevo",
"nodeVersion": "1.0", "nodeVersion": "1.0",
"codexVersion": "1.0", "codexVersion": "1.0",
"categories": ["Marketing & Content", "Communication"], "categories": ["Marketing & Content", "Communication"],
"resources": { "resources": {
"credentialDocumentation": [ "credentialDocumentation": [
{ {
"url": "https://docs.n8n.io/credentials/sendInBlue" "url": "https://docs.n8n.io/credentials/brevo"
} }
], ],
"primaryDocumentation": [ "primaryDocumentation": [
{ {
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.sendinblue/" "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.brevo/"
} }
] ]
} },
"alias": ["sendinblue"]
} }

View file

@ -5,17 +5,18 @@ import { contactFields, contactOperations } from './ContactDescription';
import { emailFields, emailOperations } from './EmailDescription'; import { emailFields, emailOperations } from './EmailDescription';
import { senderFields, senderOperations } from './SenderDescrition'; import { senderFields, senderOperations } from './SenderDescrition';
export class SendInBlue implements INodeType { export class Brevo implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
displayName: 'SendInBlue', displayName: 'Brevo',
// keep sendinblue name for backward compatibility
name: 'sendInBlue', name: 'sendInBlue',
icon: 'file:sendinblue.svg', icon: 'file:brevo.svg',
group: ['transform'], group: ['transform'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Sendinblue API', description: 'Consume Brevo API',
defaults: { defaults: {
name: 'SendInBlue', name: 'Brevo',
}, },
inputs: ['main'], inputs: ['main'],
outputs: ['main'], outputs: ['main'],
@ -26,7 +27,7 @@ export class SendInBlue implements INodeType {
}, },
], ],
requestDefaults: { requestDefaults: {
baseURL: 'https://api.sendinblue.com', baseURL: 'https://api.brevo.com',
}, },
properties: [ properties: [
{ {

View file

@ -1,19 +1,20 @@
{ {
"node": "n8n-nodes-base.sendInBlueTrigger", "node": "n8n-nodes-base.brevoTrigger",
"nodeVersion": "1.0", "nodeVersion": "1.0",
"codexVersion": "1.0", "codexVersion": "1.0",
"categories": ["Marketing & Content", "Communication"], "categories": ["Marketing & Content", "Communication"],
"resources": { "resources": {
"credentialDocumentation": [ "credentialDocumentation": [
{ {
"url": "https://docs.n8n.io/credentials/sendInBlue" "url": "https://docs.n8n.io/credentials/brevo"
} }
], ],
"primaryDocumentation": [ "primaryDocumentation": [
{ {
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.sendinbluetrigger/" "url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.brevotrigger/"
} }
], ],
"generic": [] "generic": []
} },
"alias": ["sendinblue"]
} }

View file

@ -6,9 +6,9 @@ import type {
IWebhookFunctions, IWebhookFunctions,
IWebhookResponseData, IWebhookResponseData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { SendInBlueWebhookApi } from './GenericFunctions'; import { BrevoWebhookApi } from './GenericFunctions';
export class SendInBlueTrigger implements INodeType { export class BrevoTrigger implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
credentials: [ credentials: [
{ {
@ -16,14 +16,15 @@ export class SendInBlueTrigger implements INodeType {
required: true, required: true,
}, },
], ],
displayName: 'SendInBlue Trigger', displayName: 'Brevo Trigger',
defaults: { defaults: {
name: 'SendInBlue Trigger', name: 'Brevo Trigger',
}, },
description: 'Starts the workflow when SendInBlue events occur', description: 'Starts the workflow when Brevo events occur',
group: ['trigger'], group: ['trigger'],
icon: 'file:sendinblue.svg', icon: 'file:brevo.svg',
inputs: [], inputs: [],
// keep sendinblue name for backward compatibility
name: 'sendInBlueTrigger', name: 'sendInBlueTrigger',
outputs: ['main'], outputs: ['main'],
version: 1, version: 1,
@ -213,7 +214,7 @@ export class SendInBlueTrigger implements INodeType {
const events = this.getNodeParameter('events') as string[]; const events = this.getNodeParameter('events') as string[];
try { try {
const { webhooks } = await SendInBlueWebhookApi.fetchWebhooks(this, type); const { webhooks } = await BrevoWebhookApi.fetchWebhooks(this, type);
for (const webhook of webhooks) { for (const webhook of webhooks) {
if ( if (
@ -240,12 +241,7 @@ export class SendInBlueTrigger implements INodeType {
const events = this.getNodeParameter('events') as string[]; const events = this.getNodeParameter('events') as string[];
const responseData = await SendInBlueWebhookApi.createWebHook( const responseData = await BrevoWebhookApi.createWebHook(this, type, events, webhookUrl);
this,
type,
events,
webhookUrl,
);
if (responseData === undefined || responseData.id === undefined) { if (responseData === undefined || responseData.id === undefined) {
// Required data is missing so was not successful // Required data is missing so was not successful
@ -261,7 +257,7 @@ export class SendInBlueTrigger implements INodeType {
if (webhookData.webhookId !== undefined) { if (webhookData.webhookId !== undefined) {
try { try {
await SendInBlueWebhookApi.deleteWebhook(this, webhookData.webhookId as string); await BrevoWebhookApi.deleteWebhook(this, webhookData.webhookId as string);
} catch (error) { } catch (error) {
return false; return false;
} }

View file

@ -1,5 +1,5 @@
import type { INodeProperties } from 'n8n-workflow'; import type { INodeProperties } from 'n8n-workflow';
import { SendInBlueNode } from './GenericFunctions'; import { BrevoNode } from './GenericFunctions';
export const emailOperations: INodeProperties[] = [ export const emailOperations: INodeProperties[] = [
{ {
@ -120,7 +120,7 @@ const sendHtmlEmailFields: INodeProperties[] = [
required: true, required: true,
routing: { routing: {
send: { send: {
preSend: [SendInBlueNode.Validators.validateAndCompileSenderEmail], preSend: [BrevoNode.Validators.validateAndCompileSenderEmail],
}, },
}, },
}, },
@ -138,7 +138,7 @@ const sendHtmlEmailFields: INodeProperties[] = [
required: true, required: true,
routing: { routing: {
send: { send: {
preSend: [SendInBlueNode.Validators.validateAndCompileReceipientEmails], preSend: [BrevoNode.Validators.validateAndCompileReceipientEmails],
}, },
}, },
}, },
@ -180,7 +180,7 @@ const sendHtmlEmailFields: INodeProperties[] = [
], ],
routing: { routing: {
send: { send: {
preSend: [SendInBlueNode.Validators.validateAndCompileAttachmentsData], preSend: [BrevoNode.Validators.validateAndCompileAttachmentsData],
}, },
}, },
}, },
@ -206,7 +206,7 @@ const sendHtmlEmailFields: INodeProperties[] = [
], ],
routing: { routing: {
send: { send: {
preSend: [SendInBlueNode.Validators.validateAndCompileBCCEmails], preSend: [BrevoNode.Validators.validateAndCompileBCCEmails],
}, },
}, },
}, },
@ -232,7 +232,7 @@ const sendHtmlEmailFields: INodeProperties[] = [
], ],
routing: { routing: {
send: { send: {
preSend: [SendInBlueNode.Validators.validateAndCompileCCEmails], preSend: [BrevoNode.Validators.validateAndCompileCCEmails],
}, },
}, },
}, },
@ -259,7 +259,7 @@ const sendHtmlEmailFields: INodeProperties[] = [
], ],
routing: { routing: {
send: { send: {
preSend: [SendInBlueNode.Validators.validateAndCompileTags], preSend: [BrevoNode.Validators.validateAndCompileTags],
}, },
}, },
}, },
@ -339,7 +339,7 @@ const sendHtmlTemplateEmailFields: INodeProperties[] = [
required: true, required: true,
routing: { routing: {
send: { send: {
preSend: [SendInBlueNode.Validators.validateAndCompileReceipientEmails], preSend: [BrevoNode.Validators.validateAndCompileReceipientEmails],
}, },
}, },
}, },
@ -381,7 +381,7 @@ const sendHtmlTemplateEmailFields: INodeProperties[] = [
], ],
routing: { routing: {
send: { send: {
preSend: [SendInBlueNode.Validators.validateAndCompileAttachmentsData], preSend: [BrevoNode.Validators.validateAndCompileAttachmentsData],
}, },
}, },
}, },
@ -408,7 +408,7 @@ const sendHtmlTemplateEmailFields: INodeProperties[] = [
], ],
routing: { routing: {
send: { send: {
preSend: [SendInBlueNode.Validators.validateAndCompileTags], preSend: [BrevoNode.Validators.validateAndCompileTags],
}, },
}, },
}, },
@ -437,7 +437,7 @@ const sendHtmlTemplateEmailFields: INodeProperties[] = [
], ],
routing: { routing: {
send: { send: {
preSend: [SendInBlueNode.Validators.validateAndCompileTemplateParameters], preSend: [BrevoNode.Validators.validateAndCompileTemplateParameters],
}, },
}, },
}, },

View file

@ -8,7 +8,7 @@ import type {
import { jsonParse, NodeOperationError } from 'n8n-workflow'; import { jsonParse, NodeOperationError } from 'n8n-workflow';
import type { OptionsWithUri } from 'request'; import type { OptionsWithUri } from 'request';
import MailComposer from 'nodemailer/lib/mail-composer'; import MailComposer from 'nodemailer/lib/mail-composer';
export namespace SendInBlueNode { export namespace BrevoNode {
type ValidEmailFields = { to: string } | { sender: string } | { cc: string } | { bcc: string }; type ValidEmailFields = { to: string } | { sender: string } | { cc: string } | { bcc: string };
type Address = { address: string; name?: string }; type Address = { address: string; name?: string };
type Email = { email: string; name?: string }; type Email = { email: string; name?: string };
@ -277,7 +277,7 @@ export namespace SendInBlueNode {
} }
} }
export namespace SendInBlueWebhookApi { export namespace BrevoWebhookApi {
interface WebhookDetails { interface WebhookDetails {
url: string; url: string;
id: number; id: number;
@ -297,7 +297,7 @@ export namespace SendInBlueWebhookApi {
} }
const credentialsName = 'sendInBlueApi'; const credentialsName = 'sendInBlueApi';
const baseURL = 'https://api.sendinblue.com/v3'; const baseURL = 'https://api.brevo.com/v3';
export const supportedAuthMap = new Map<string, (ref: IWebhookFunctions) => Promise<string>>([ export const supportedAuthMap = new Map<string, (ref: IWebhookFunctions) => Promise<string>>([
[ [
'apiKey', 'apiKey',

View file

@ -0,0 +1,4 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="24" fill="#0B996E"/>
<path d="M31.5036 21.8092C32.9875 20.3562 33.6829 18.677 33.6829 16.6345C33.6829 12.4146 30.5772 9.59998 25.8963 9.59998H14.4V39.6H23.6705C30.717 39.6 36 35.2887 36 29.5702C36 26.438 34.3782 23.6253 31.5036 21.8092ZM18.572 13.5024H25.4321C27.7492 13.5024 29.2797 14.8184 29.2797 16.8152C29.2797 19.084 27.287 20.8089 23.2082 22.1249C20.4269 22.9863 19.176 23.7128 18.7118 24.5762L18.572 24.5775V13.5024ZM23.2995 35.6976H18.572V31.0688C18.572 29.0263 20.3336 27.0295 22.7906 26.2573C24.9698 25.5309 26.7761 24.8044 28.3067 24.0342C30.346 25.2152 31.5969 27.2558 31.5969 29.3895C31.5969 33.0199 28.0736 35.6976 23.2995 35.6976Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 800 B

View file

@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.crowdDev",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Productivity"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/credentials/crowdDev"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.crowdDev/"
}
]
}
}

View file

@ -0,0 +1,32 @@
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
import { allProperties } from './descriptions';
export class CrowdDev implements INodeType {
description: INodeTypeDescription = {
displayName: 'crowd.dev',
name: 'crowdDev',
icon: 'file:crowdDev.svg',
group: ['transform'],
version: 1,
subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}',
description:
'crowd.dev is an open-source suite of community and data tools built to unlock community-led growth for your organization.',
defaults: {
name: 'crowd.dev',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'crowdDevApi',
required: true,
},
],
requestDefaults: {
baseURL: '={{$credentials.url}}/api/tenant/{{$credentials.tenantId}}',
json: true,
skipSslCertificateValidation: '={{ $credentials.allowUnauthorizedCerts }}',
},
properties: allProperties,
};
}

View file

@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.crowdDevTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Productivity"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/credentials/crowdDev"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.crowddevtrigger/"
}
]
}
}

View file

@ -0,0 +1,185 @@
import type {
IHookFunctions,
IWebhookFunctions,
INodeType,
INodeTypeDescription,
IWebhookResponseData,
IHttpRequestOptions,
} from 'n8n-workflow';
interface ICrowdDevCreds {
url: string;
tenantId: string;
token: string;
allowUnauthorizedCerts: boolean;
}
const credsName = 'crowdDevApi';
const getCreds = async (hookFns: IHookFunctions) =>
hookFns.getCredentials(credsName) as unknown as ICrowdDevCreds;
const createRequest = (
creds: ICrowdDevCreds,
opts: Partial<IHttpRequestOptions>,
): IHttpRequestOptions => {
const defaults: IHttpRequestOptions = {
baseURL: `${creds.url}/api/tenant/${creds.tenantId}`,
url: '',
json: true,
skipSslCertificateValidation: creds.allowUnauthorizedCerts,
};
return Object.assign(defaults, opts);
};
export class CrowdDevTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'crowd.dev Trigger',
name: 'crowdDevTrigger',
icon: 'file:crowdDev.svg',
group: ['trigger'],
version: 1,
description: 'Starts the workflow when crowd.dev events occur.',
defaults: {
name: 'crowd.dev Trigger',
},
inputs: [],
outputs: ['main'],
credentials: [
{
name: 'crowdDevApi',
required: true,
},
],
webhooks: [
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
path: 'webhook',
},
],
properties: [
{
displayName: 'Trigger',
name: 'trigger',
description: 'What will trigger an automation',
type: 'options',
required: true,
default: 'new_activity',
options: [
{
name: 'New Activity',
value: 'new_activity',
},
{
name: 'New Member',
value: 'new_member',
},
],
},
],
};
webhookMethods = {
default: {
async checkExists(this: IHookFunctions): Promise<boolean> {
const creds = await getCreds(this);
const webhookData = this.getWorkflowStaticData('node');
const webhookUrl = this.getNodeWebhookUrl('default') as string;
if (webhookData.webhookId !== undefined) {
try {
const options = createRequest(creds, {
url: `/automation/${webhookData.webhookId}`,
method: 'GET',
});
const data = await this.helpers.httpRequestWithAuthentication.call(
this,
credsName,
options,
);
if (data.settings.url === webhookUrl) {
return true;
}
} catch (error) {
return false;
}
}
// If it did not error then the webhook exists
return false;
},
async create(this: IHookFunctions): Promise<boolean> {
const creds = await getCreds(this);
const webhookData = this.getWorkflowStaticData('node');
const webhookUrl = this.getNodeWebhookUrl('default') as string;
const params = {
trigger: this.getNodeParameter('trigger') as string,
};
const options = createRequest(creds, {
url: '/automation',
method: 'POST',
body: {
data: {
settings: {
url: webhookUrl,
},
type: 'webhook',
trigger: params.trigger,
},
},
});
const responseData = await this.helpers.httpRequestWithAuthentication.call(
this,
'crowdDevApi',
options,
);
if (responseData === undefined || responseData.id === undefined) {
// Required data is missing so was not successful
return false;
}
webhookData.webhookId = responseData.id as string;
return true;
},
async delete(this: IHookFunctions): Promise<boolean> {
const creds = await getCreds(this);
const webhookData = this.getWorkflowStaticData('node');
if (webhookData.webhookId !== undefined) {
try {
const options = createRequest(creds, {
url: `/automation/${webhookData.webhookId}`,
method: 'DELETE',
});
await this.helpers.httpRequestWithAuthentication.call(this, credsName, options);
} catch (error) {
return false;
}
// Remove from the static workflow data so that it is clear
// that no webhooks are registered anymore
delete webhookData.webhookId;
delete webhookData.webhookEvents;
delete webhookData.hookSecret;
}
return true;
},
},
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const bodyData = this.getBodyData();
return {
workflowData: [this.helpers.returnJsonArray(bodyData)],
};
}
}

View file

@ -0,0 +1,221 @@
import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow';
const addOptName = 'additionalOptions';
const getAllParams = (execFns: IExecuteSingleFunctions): Record<string, unknown> => {
const params = execFns.getNode().parameters;
const keys = Object.keys(params);
const paramsWithValues = keys
.filter((i) => i !== addOptName)
.map((name) => [name, execFns.getNodeParameter(name)]);
const paramsWithValuesObj = Object.fromEntries(paramsWithValues);
if (keys.includes(addOptName)) {
const additionalOptions = execFns.getNodeParameter(addOptName);
return Object.assign(paramsWithValuesObj, additionalOptions);
}
return paramsWithValuesObj;
};
const formatParams = (
obj: Record<string, unknown>,
filters?: { [paramName: string]: (value: any) => boolean },
mappers?: { [paramName: string]: (value: any) => any },
) => {
return Object.fromEntries(
Object.entries(obj)
.filter(([name, value]) => !filters || (name in filters ? filters[name](value) : false))
.map(([name, value]) =>
!mappers || !(name in mappers) ? [name, value] : [name, mappers[name](value)],
),
);
};
const objectFromProps = (src: any, props: string[]) => {
const result = props.filter((p) => src.hasOwnProperty(p)).map((p) => [p, src[p]]);
return Object.fromEntries(result);
};
const idFn = (i: any) => i;
const keyValueToObj = (arr: any[]) => {
const obj: any = {};
arr.forEach((item) => {
obj[item.key] = item.value;
});
return obj;
};
const transformSingleProp = (prop: string) => (values: any) =>
(values.itemChoice || []).map((i: any) => i[prop]);
export async function activityPresend(
this: IExecuteSingleFunctions,
opts: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const params = getAllParams(this);
const isCreateWithMember = params.operation === 'createWithMember';
const isCreateForMember = params.operation === 'createForMember';
if (isCreateWithMember) {
// Move following props into "member" subproperty
const memberProps = ['displayName', 'emails', 'joinedAt', 'username'];
params.member = objectFromProps(params, memberProps);
memberProps.forEach((p) => delete params[p]);
}
opts.body = formatParams(
params,
{
member: (v) => (isCreateWithMember || isCreateForMember) && v,
type: idFn,
timestamp: idFn,
platform: idFn,
title: idFn,
body: idFn,
channel: idFn,
sourceId: idFn,
sourceParentId: idFn,
},
{
member: (v) =>
typeof v === 'object'
? formatParams(
v as Record<string, unknown>,
{
username: (un) => un.itemChoice,
displayName: idFn,
emails: idFn,
joinedAt: idFn,
},
{
username: (un) => keyValueToObj(un.itemChoice as any[]),
emails: transformSingleProp('email'),
},
)
: v,
},
);
return opts;
}
export async function automationPresend(
this: IExecuteSingleFunctions,
opts: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const params = getAllParams(this);
opts.body = {
data: {
settings: {
url: params.url,
},
type: 'webhook',
trigger: params.trigger,
},
};
return opts;
}
export async function memberPresend(
this: IExecuteSingleFunctions,
opts: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const params = getAllParams(this);
opts.body = formatParams(
params,
{
platform: idFn,
username: idFn,
displayName: idFn,
emails: (i) => i.itemChoice,
joinedAt: idFn,
organizations: (i) => i.itemChoice,
tags: (i) => i.itemChoice,
tasks: (i) => i.itemChoice,
notes: (i) => i.itemChoice,
activities: (i) => i.itemChoice,
},
{
emails: transformSingleProp('email'),
organizations: (i) =>
i.itemChoice.map((org: any) =>
formatParams(
org as Record<string, unknown>,
{
name: idFn,
url: idFn,
description: idFn,
logo: idFn,
employees: idFn,
members: (j) => j.itemChoice,
},
{
members: transformSingleProp('member'),
},
),
),
tags: transformSingleProp('tag'),
tasks: transformSingleProp('task'),
notes: transformSingleProp('note'),
activities: transformSingleProp('activity'),
},
);
return opts;
}
export async function notePresend(
this: IExecuteSingleFunctions,
opts: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const params = getAllParams(this);
opts.body = {
body: params.body,
};
return opts;
}
export async function organizationPresend(
this: IExecuteSingleFunctions,
opts: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const params = getAllParams(this);
opts.body = formatParams(
params,
{
name: idFn,
url: idFn,
description: idFn,
logo: idFn,
employees: idFn,
members: (j) => j.itemChoice,
},
{
members: transformSingleProp('member'),
},
);
return opts;
}
export async function taskPresend(
this: IExecuteSingleFunctions,
opts: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const params = getAllParams(this);
opts.body = formatParams(
params,
{
name: idFn,
body: idFn,
status: idFn,
members: (i) => i.itemChoice,
activities: (i) => i.itemChoice,
assigneess: idFn,
},
{
members: transformSingleProp('member'),
activities: transformSingleProp('activity'),
},
);
return opts;
}

View file

@ -0,0 +1,15 @@
<svg width="131" height="24" viewBox="0 0 131 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M57.3097 1.04274C57.4165 1.06739 57.4993 1.15158 57.5223 1.25872L58.1022 3.9642C58.1357 4.12016 58.0334 4.27285 57.8764 4.30128L52.2156 5.32696C52.028 5.36095 51.8614 5.20312 51.8851 5.01395L52.4824 0.247384C52.5032 0.0813045 52.6631 -0.0303341 52.8262 0.00732829L57.3097 1.04274Z" fill="#E94F2E"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M88.9753 23.6416C90.9399 23.6416 92.6615 22.9127 93.7118 21.6226C93.8883 21.4059 94.2929 21.4955 94.3218 21.7735L94.4476 22.9845C94.4626 23.1284 94.5838 23.2378 94.7285 23.2378H97.6708C97.8268 23.2378 97.9532 23.1113 97.9532 22.9553V0.407414C97.9532 0.251441 97.8268 0.125 97.6708 0.125H94.4767C94.3208 0.125 94.1943 0.25144 94.1943 0.407413V8.87388C94.1943 9.15681 93.7865 9.28936 93.5874 9.0883C92.5408 8.03107 90.9503 7.4254 89.1928 7.4254C88.3496 7.4254 87.5673 7.53878 86.8516 7.75317V11.9657C87.577 11.236 88.5844 10.8115 89.8141 10.8115C92.4547 10.8115 94.1633 12.7376 94.1633 15.5024C94.1633 18.2673 92.4547 20.1623 89.8141 20.1623C88.5844 20.1623 87.577 19.7445 86.8516 19.0221V23.365C87.5069 23.5465 88.2167 23.6416 88.9753 23.6416ZM107.836 23.6416C111.709 23.6416 114.45 21.7562 115.219 18.6021C115.261 18.4296 115.127 18.2673 114.95 18.2673H111.992C111.867 18.2673 111.759 18.3501 111.715 18.4671C111.218 19.8009 109.883 20.5351 107.899 20.5351C105.481 20.5351 104.067 19.2729 103.712 16.7761C103.688 16.6106 103.819 16.4651 103.986 16.4646L114.948 16.4352C115.104 16.4347 115.23 16.3084 115.23 16.1527V15.285C115.23 10.5009 112.31 7.4254 107.712 7.4254C103.208 7.4254 100.07 10.7494 100.07 15.5646C100.07 20.3176 103.27 23.6416 107.836 23.6416ZM107.743 10.532C109.908 10.532 111.337 11.8061 111.462 13.7596C111.472 13.9153 111.345 14.0424 111.189 14.0424H104.099C103.925 14.0424 103.792 13.8857 103.828 13.7155C104.274 11.6288 105.615 10.532 107.743 10.532ZM124.813 23.064C124.77 23.1692 124.667 23.2378 124.553 23.2378H121.205C121.089 23.2378 120.985 23.1671 120.942 23.0595L115.078 8.27795C115.004 8.09255 115.141 7.89138 115.34 7.89138H118.768C118.885 7.89138 118.99 7.96398 119.032 8.07374L121.634 14.9433C122.045 16.0969 122.406 17.1521 122.664 17.9849C122.754 18.273 123.25 18.2731 123.341 17.9856C123.618 17.1075 124.004 16.0386 124.43 14.9433L127.155 8.06971C127.198 7.96207 127.302 7.89138 127.418 7.89138H130.717C130.918 7.89138 131.055 8.09646 130.977 8.28249L124.813 23.064Z" fill="url(#paint0_linear_470_15680)"/>
<path d="M0 15.5335C0 20.4108 3.04442 23.6416 7.70425 23.6416C11.5916 23.6416 14.5144 21.3323 15.1114 17.9329C15.1408 17.765 15.0088 17.6149 14.8383 17.6149H11.5614C11.4309 17.6149 11.3184 17.705 11.2808 17.83C10.8176 19.3719 9.51665 20.2244 7.70425 20.2244C5.28114 20.2244 3.78999 18.4226 3.78999 15.5335C3.78999 12.6444 5.4054 10.8115 7.82851 10.8115C9.55529 10.8115 10.797 11.639 11.2803 13.2392C11.3177 13.3632 11.4298 13.4521 11.5594 13.4521H14.814C14.9821 13.4521 15.1134 13.3058 15.0884 13.1395C14.5611 9.62446 11.7476 7.4254 7.67318 7.4254C3.13762 7.4254 0 10.7805 0 15.5335Z" fill="#140505"/>
<path d="M26.6792 8.04975C26.6792 7.92016 26.5911 7.80665 26.4646 7.77846C25.9342 7.66022 25.4679 7.61179 25.0017 7.61179C23.4123 7.61179 22.1776 8.22781 21.3989 9.21386C21.2178 9.44318 20.7606 9.35331 20.7323 9.06247L20.6462 8.17753C20.6322 8.03283 20.5105 7.92245 20.3652 7.92245H17.3313C17.1753 7.92245 17.0489 8.04889 17.0489 8.20486V22.9553C17.0489 23.1113 17.1753 23.2378 17.3313 23.2378H20.5565C20.7125 23.2378 20.8389 23.1113 20.8389 22.9553V15.782C20.8389 12.7997 22.5475 11.3397 25.2813 11.3397H26.3968C26.5528 11.3397 26.6792 11.2132 26.6792 11.0572V8.04975Z" fill="#140505"/>
<path d="M26.8959 15.5335C26.8959 20.3176 30.3442 23.6105 35.0972 23.6105C39.8503 23.6105 43.2985 20.3176 43.2985 15.5335C43.2985 10.7494 39.8503 7.45646 35.0972 7.45646C30.3442 7.45646 26.8959 10.7494 26.8959 15.5335ZM30.6859 15.5335C30.6859 12.7376 32.4877 10.8426 35.0972 10.8426C37.7068 10.8426 39.5085 12.7376 39.5085 15.5335C39.5085 18.3294 37.7068 20.2244 35.0972 20.2244C32.4877 20.2244 30.6859 18.3294 30.6859 15.5335Z" fill="#140505"/>
<path d="M73.9835 23.6416C75.9481 23.6416 77.6697 22.9127 78.72 21.6226C78.8964 21.4059 79.3011 21.4955 79.33 21.7735L79.4558 22.9845C79.4707 23.1284 79.592 23.2378 79.7367 23.2378H82.679C82.835 23.2378 82.9614 23.1113 82.9614 22.9553V0.407414C82.9614 0.251441 82.835 0.125 82.679 0.125H79.4849C79.329 0.125 79.2025 0.251441 79.2025 0.407414V8.87388C79.2025 9.15681 78.7947 9.28936 78.5957 9.0883C77.549 8.03107 75.9585 7.4254 74.201 7.4254C69.5722 7.4254 66.7763 10.8426 66.7763 15.6267C66.7763 20.3797 69.5411 23.6416 73.9835 23.6416ZM74.8223 20.1623C72.1817 20.1623 70.5663 18.2362 70.5663 15.5024C70.5663 12.7687 72.1817 10.8115 74.8223 10.8115C77.4629 10.8115 79.1714 12.7376 79.1714 15.5024C79.1714 18.2673 77.4629 20.1623 74.8223 20.1623Z" fill="#140505"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M44.468 11.2986C44.2998 11.3291 44.197 11.5008 44.2497 11.6635L47.9351 23.0424C47.9729 23.1588 48.0814 23.2378 48.2038 23.2378H51.4892C51.612 23.2378 51.7206 23.1585 51.7581 23.0416L53.8077 16.6519C54.202 15.3742 54.4682 14.465 54.655 13.7859C54.7379 13.4849 55.2539 13.4953 55.3278 13.7986C55.5105 14.5481 55.7667 15.4853 56.1065 16.5897L58.1564 23.0409C58.1937 23.1581 58.3025 23.2378 58.4256 23.2378H61.5593C61.6797 23.2378 61.7868 23.1615 61.8262 23.0477L66.9429 8.26618C67.0064 8.08272 66.8702 7.89138 66.676 7.89138H63.2731C63.1494 7.89138 63.0402 7.9718 63.0034 8.08985L61.0149 14.4773C60.8324 15.1057 60.5574 16.1045 60.3192 17.042C60.2432 17.3415 59.7535 17.3377 59.6796 17.0377C59.4283 16.0181 59.1181 14.8855 58.9956 14.4773L57.0071 8.08985C56.9703 7.9718 56.8611 7.89138 56.7374 7.89138H53.2992C53.1764 7.89138 53.0677 7.97066 53.0302 8.08754L50.9807 14.4773C50.6607 15.4573 50.4308 16.2056 50.233 17.0122C50.1589 17.3145 49.6915 17.3143 49.6243 17.0104C49.427 16.1185 49.2164 15.2712 48.9925 14.4773L47.9046 10.929C47.8627 10.7922 47.725 10.7084 47.5843 10.7339L44.468 11.2986Z" fill="#140505"/>
<defs>
<linearGradient id="paint0_linear_470_15680" x1="93.4995" y1="15.0132" x2="84.4995" y2="16.0133" gradientUnits="userSpaceOnUse">
<stop stop-color="#E94F2E"/>
<stop offset="1" stop-color="#CA2400"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

View file

@ -0,0 +1,186 @@
import type { INodeProperties } from 'n8n-workflow';
import { activityPresend } from '../GenericFunctions';
import { emailsField } from './shared';
import { getAdditionalOptions, mapWith, showFor } from './utils';
const displayOpts = showFor(['activity']);
const displayFor = {
resource: displayOpts(),
createWithMember: displayOpts(['createWithMember']),
createForMember: displayOpts(['createForMember']),
};
const activityOperations: INodeProperties = {
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: displayFor.resource.displayOptions,
noDataExpression: true,
default: 'createWithMember',
options: [
{
name: 'Create or Update with a Member',
value: 'createWithMember',
description: 'Create or update an activity with a member',
action: 'Create or update an activity with a member',
routing: {
send: { preSend: [activityPresend] },
request: {
method: 'POST',
url: '/activity/with-member',
},
},
},
{
name: 'Create',
value: 'createForMember',
description: 'Create an activity for a member',
action: 'Create an activity for a member',
routing: {
send: { preSend: [activityPresend] },
request: {
method: 'POST',
url: '/activity',
},
},
},
],
};
const createWithMemberFields: INodeProperties[] = [
{
displayName: 'Username',
name: 'username',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
required: true,
default: {},
options: [
{
displayName: 'Item Choice',
name: 'itemChoice',
values: [
{
displayName: 'Platform',
description: 'Platform name (e.g twitter, github, etc)',
name: 'key',
type: 'string',
required: true,
default: '',
},
{
displayName: 'Username',
description: 'Username at the specified Platform',
name: 'value',
type: 'string',
required: true,
default: '',
},
],
},
],
},
{
displayName: 'displayName',
name: 'displayName',
description: 'UI friendly name of the member',
type: 'string',
default: '',
},
emailsField,
{
displayName: 'Joined At',
name: 'joinedAt',
description: 'Date of joining the community',
type: 'dateTime',
default: '',
},
];
const memberIdField: INodeProperties = {
displayName: 'Member',
name: 'member',
description: 'The ID of the member that performed the activity',
type: 'string',
required: true,
default: '',
};
const createCommonFields: INodeProperties[] = [
{
displayName: 'Type',
name: 'type',
description: 'Type of activity',
type: 'string',
required: true,
default: '',
},
{
displayName: 'Timestamp',
name: 'timestamp',
description: 'Date and time when the activity took place',
type: 'dateTime',
required: true,
default: '',
},
{
displayName: 'Platform',
name: 'platform',
description: 'Platform on which the activity took place',
type: 'string',
required: true,
default: '',
},
{
displayName: 'Source ID',
name: 'sourceId',
description: 'The ID of the activity in the platform (e.g. the ID of the message in Discord)',
type: 'string',
required: true,
default: '',
},
];
const additionalOptions: INodeProperties[] = [
{
displayName: 'Title',
name: 'title',
description: 'Title of the activity',
type: 'string',
default: '',
},
{
displayName: 'Body',
name: 'body',
description: 'Body of the activity',
type: 'string',
default: '',
},
{
displayName: 'Channel',
name: 'channel',
description: 'Channel of the activity',
type: 'string',
default: '',
},
{
displayName: 'Source Parent ID',
name: 'sourceParentId',
description:
'The ID of the parent activity in the platform (e.g. the ID of the parent message in Discord)',
type: 'string',
default: '',
},
];
const activityFields: INodeProperties[] = [
...createWithMemberFields.map(mapWith(displayFor.createWithMember)),
Object.assign({}, memberIdField, displayFor.createForMember),
...createCommonFields.map(mapWith(displayFor.resource)),
Object.assign({}, getAdditionalOptions(additionalOptions), displayFor.resource),
];
export { activityOperations, activityFields };

View file

@ -0,0 +1,129 @@
import type { INodeProperties } from 'n8n-workflow';
import { automationPresend } from '../GenericFunctions';
import { mapWith, showFor } from './utils';
const displayOpts = showFor(['automation']);
const displayFor = {
resource: displayOpts(),
createOrUpdate: displayOpts(['create', 'update']),
id: displayOpts(['destroy', 'find', 'update']),
};
const automationOperations: INodeProperties = {
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: displayFor.resource.displayOptions,
noDataExpression: true,
default: 'list',
options: [
{
name: 'Create',
value: 'create',
description: 'Create a new automation for the tenant',
action: 'Create a new automation for the tenant',
routing: {
send: { preSend: [automationPresend] },
request: {
method: 'POST',
url: '/automation',
},
},
},
{
name: 'Destroy',
value: 'destroy',
description: 'Destroy an existing automation for the tenant',
action: 'Destroy an existing automation for the tenant',
routing: {
request: {
method: 'DELETE',
url: '=/automation/{{$parameter["id"]}}',
},
},
},
{
name: 'Find',
value: 'find',
description: 'Get an existing automation data for the tenant',
action: 'Get an existing automation data for the tenant',
routing: {
request: {
method: 'GET',
url: '=/automation/{{$parameter["id"]}}',
},
},
},
{
name: 'List',
value: 'list',
description: 'Get all existing automation data for tenant',
action: 'Get all existing automation data for tenant',
routing: {
request: {
method: 'GET',
url: '/automation',
},
},
},
{
name: 'Update',
value: 'update',
description: 'Updates an existing automation for the tenant',
action: 'Updates an existing automation for the tenant',
routing: {
send: { preSend: [automationPresend] },
request: {
method: 'PUT',
url: '=/automation/{{$parameter["id"]}}',
},
},
},
],
};
const idField: INodeProperties = {
displayName: 'ID',
name: 'id',
description: 'The ID of the automation',
type: 'string',
required: true,
default: '',
};
const commonFields: INodeProperties[] = [
{
displayName: 'Trigger',
name: 'trigger',
description: 'What will trigger an automation',
type: 'options',
required: true,
default: 'new_activity',
options: [
{
name: 'New Activity',
value: 'new_activity',
},
{
name: 'New Member',
value: 'new_member',
},
],
},
{
displayName: 'URL',
name: 'url',
description: 'URL to POST webhook data to',
type: 'string',
required: true,
default: '',
},
];
const automationFields: INodeProperties[] = [
Object.assign({}, idField, displayFor.id),
...commonFields.map(mapWith(displayFor.createOrUpdate)),
];
export { automationOperations, automationFields };

View file

@ -0,0 +1,24 @@
import { resources } from './resources';
import { activityOperations, activityFields } from './activityFields';
import { memberFields, memberOperations } from './memberFields';
import { noteFields, noteOperations } from './noteFields';
import { organizationFields, organizationOperations } from './organizationFields';
import { taskFields, taskOperations } from './taskFields';
import type { INodeProperties } from 'n8n-workflow';
import { automationFields, automationOperations } from './automationFields';
export const allProperties: INodeProperties[] = [
resources,
activityOperations,
memberOperations,
noteOperations,
organizationOperations,
taskOperations,
automationOperations,
...activityFields,
...memberFields,
...noteFields,
...organizationFields,
...taskFields,
...automationFields,
];

View file

@ -0,0 +1,275 @@
import type { INodeProperties } from 'n8n-workflow';
import { getAdditionalOptions, getId, mapWith, showFor } from './utils';
import * as shared from './shared';
import { memberPresend } from '../GenericFunctions';
const displayOpts = showFor(['member']);
const displayFor = {
resource: displayOpts(),
createOrUpdate: displayOpts(['createOrUpdate', 'update']),
id: displayOpts(['delete', 'find', 'update']),
};
const memberOperations: INodeProperties = {
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: displayFor.resource.displayOptions,
noDataExpression: true,
default: 'find',
options: [
{
name: 'Create or Update',
value: 'createOrUpdate',
description: 'Create or update a member',
action: 'Create or update a member',
routing: {
send: { preSend: [memberPresend] },
request: {
method: 'POST',
url: '/member',
},
},
},
{
name: 'Delete',
value: 'delete',
description: 'Delete a member',
action: 'Delete a member',
routing: {
request: {
method: 'DELETE',
url: '=/member',
},
},
},
{
name: 'Find',
value: 'find',
description: 'Find a member',
action: 'Find a member',
routing: {
request: {
method: 'GET',
url: '=/member/{{$parameter["id"]}}',
},
},
},
{
name: 'Update',
value: 'update',
description: 'Update a member',
action: 'Update a member',
routing: {
send: { preSend: [memberPresend] },
request: {
method: 'PUT',
url: '=/member/{{$parameter["id"]}}',
},
},
},
],
};
const commonFields: INodeProperties[] = [
{
displayName: 'Platform',
name: 'platform',
description: 'Platform for which to check member existence',
type: 'string',
required: true,
default: '',
},
{
displayName: 'Username',
name: 'username',
description: 'Username of the member in platform',
type: 'string',
required: true,
default: '',
},
];
const additionalOptions: INodeProperties[] = [
{
displayName: 'Display Name',
name: 'displayName',
description: 'UI friendly name of the member',
type: 'string',
default: '',
},
shared.emailsField,
{
displayName: 'Joined At',
name: 'joinedAt',
description: 'Date of joining the community',
type: 'dateTime',
default: '',
},
{
displayName: 'Organizations',
name: 'organizations',
description:
'Organizations associated with the member. Each element in the array is the name of the organization, or an organization object. If the organization does not exist, it will be created.',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
displayName: 'Item Choice',
name: 'itemChoice',
values: [
{
displayName: 'Name',
name: 'name',
description: 'The name of the organization',
type: 'string',
required: true,
default: '',
},
{
displayName: 'Url',
name: 'url',
description: 'The URL of the organization',
type: 'string',
default: '',
},
{
displayName: 'Description',
name: 'description',
description: 'A short description of the organization',
type: 'string',
typeOptions: {
rows: 3,
},
default: '',
},
{
displayName: 'Logo',
name: 'logo',
description: 'A URL for logo of the organization',
type: 'string',
default: '',
},
{
displayName: 'Employees',
name: 'employees',
description: 'The number of employees of the organization',
type: 'number',
default: '',
},
],
},
],
},
{
displayName: 'Tags',
name: 'tags',
description: 'Tags associated with the member. Each element in the array is the ID of the tag.',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
displayName: 'Item Choice',
name: 'itemChoice',
values: [
{
displayName: 'Tag',
name: 'tag',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Tasks',
name: 'tasks',
description:
'Tasks associated with the member. Each element in the array is the ID of the task.',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
displayName: 'Item Choice',
name: 'itemChoice',
values: [
{
displayName: 'Task',
name: 'task',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Notes',
name: 'notes',
description:
'Notes associated with the member. Each element in the array is the ID of the note.',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
displayName: 'Item Choice',
name: 'itemChoice',
values: [
{
displayName: 'Note',
name: 'note',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Activities',
name: 'activities',
description:
'Activities associated with the member. Each element in the array is the ID of the activity.',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
displayName: 'Item Choice',
name: 'itemChoice',
values: [
{
displayName: 'Activity',
name: 'activity',
type: 'string',
default: '',
},
],
},
],
},
];
const memberFields: INodeProperties[] = [
Object.assign(getId(), { description: 'The ID of the member' }, displayFor.id),
...commonFields.map(mapWith(displayFor.createOrUpdate)),
Object.assign({}, getAdditionalOptions(additionalOptions), displayFor.createOrUpdate),
];
export { memberOperations, memberFields };

View file

@ -0,0 +1,92 @@
import type { INodeProperties } from 'n8n-workflow';
import { notePresend } from '../GenericFunctions';
import { getId, mapWith, showFor } from './utils';
const displayOpts = showFor(['note']);
const displayFor = {
resource: displayOpts(),
createOrUpdate: displayOpts(['create', 'update']),
id: displayOpts(['delete', 'find', 'update']),
};
const noteOperations: INodeProperties = {
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: displayFor.resource.displayOptions,
noDataExpression: true,
default: 'find',
options: [
{
name: 'Create',
value: 'create',
description: 'Create a note',
action: 'Create a note',
routing: {
send: { preSend: [notePresend] },
request: {
method: 'POST',
url: '/note',
},
},
},
{
name: 'Delete',
value: 'delete',
description: 'Delete a note',
action: 'Delete a note',
routing: {
request: {
method: 'DELETE',
url: '=/note',
},
},
},
{
name: 'Find',
value: 'find',
description: 'Find a note',
action: 'Find a note',
routing: {
request: {
method: 'GET',
url: '=/note/{{$parameter["id"]}}',
},
},
},
{
name: 'Update',
value: 'update',
description: 'Update a note',
action: 'Update a note',
routing: {
send: { preSend: [notePresend] },
request: {
method: 'PUT',
url: '=/note/{{$parameter["id"]}}',
},
},
},
],
};
const commonFields: INodeProperties[] = [
{
displayName: 'Body',
name: 'body',
description: 'The body of the note',
type: 'string',
typeOptions: {
rows: 4,
},
default: '',
},
];
const noteFields: INodeProperties[] = [
Object.assign(getId(), { description: 'The ID of the note' }, displayFor.id),
...commonFields.map(mapWith(displayFor.createOrUpdate)),
];
export { noteOperations, noteFields };

View file

@ -0,0 +1,150 @@
import type { INodeProperties } from 'n8n-workflow';
import { organizationPresend } from '../GenericFunctions';
import { getAdditionalOptions, getId, mapWith, showFor } from './utils';
const displayOpts = showFor(['organization']);
const displayFor = {
resource: displayOpts(),
createOrUpdate: displayOpts(['create', 'update']),
id: displayOpts(['delete', 'find', 'update']),
};
const organizationOperations: INodeProperties = {
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: displayFor.resource.displayOptions,
noDataExpression: true,
default: 'find',
options: [
{
name: 'Create',
value: 'create',
description: 'Create an organization',
action: 'Create an organization',
routing: {
send: { preSend: [organizationPresend] },
request: {
method: 'POST',
url: '/organization',
},
},
},
{
name: 'Delete',
value: 'delete',
description: 'Delete an organization',
action: 'Delete an organization',
routing: {
request: {
method: 'DELETE',
url: '=/organization',
},
},
},
{
name: 'Find',
value: 'find',
description: 'Find an organization',
action: 'Find an organization',
routing: {
request: {
method: 'GET',
url: '=/organization/{{$parameter["id"]}}',
},
},
},
{
name: 'Update',
value: 'update',
description: 'Update an organization',
action: 'Update an organization',
routing: {
send: { preSend: [organizationPresend] },
request: {
method: 'PUT',
url: '=/organization/{{$parameter["id"]}}',
},
},
},
],
};
const commonFields: INodeProperties[] = [
{
displayName: 'Name',
name: 'name',
description: 'The name of the organization',
type: 'string',
required: true,
default: '',
},
];
const additionalOptions: INodeProperties[] = [
{
displayName: 'Url',
name: 'url',
description: 'The URL of the organization',
type: 'string',
default: '',
},
{
displayName: 'Description',
name: 'description',
description: 'A short description of the organization',
type: 'string',
typeOptions: {
rows: 3,
},
default: '',
},
{
displayName: 'Logo',
name: 'logo',
description: 'A URL for logo of the organization',
type: 'string',
default: '',
},
{
displayName: 'Employees',
name: 'employees',
description: 'The number of employees of the organization',
type: 'number',
default: '',
},
{
displayName: 'Members',
name: 'members',
description:
'Members associated with the organization. Each element in the array is the ID of the member.',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
displayName: 'Item Choice',
name: 'itemChoice',
values: [
{
displayName: 'Member',
name: 'member',
type: 'string',
default: '',
},
],
},
],
},
];
const organizationFields: INodeProperties[] = [
Object.assign(getId(), { description: 'The ID of the organization' }, displayFor.id),
...commonFields.map(mapWith(displayFor.createOrUpdate)),
Object.assign({}, getAdditionalOptions(additionalOptions), displayFor.createOrUpdate),
];
export { organizationOperations, organizationFields };

View file

@ -0,0 +1,36 @@
import type { INodeProperties } from 'n8n-workflow';
export const resources: INodeProperties = {
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
default: 'activity',
placeholder: 'Resourcee',
options: [
{
name: 'Activity',
value: 'activity',
},
{
name: 'Automation',
value: 'automation',
},
{
name: 'Member',
value: 'member',
},
{
name: 'Note',
value: 'note',
},
{
name: 'Organization',
value: 'organization',
},
{
name: 'Task',
value: 'task',
},
],
};

View file

@ -0,0 +1,27 @@
import type { INodeProperties } from 'n8n-workflow';
export const emailsField: INodeProperties = {
displayName: 'Emails',
name: 'emails',
description: 'Email addresses of the member',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
displayName: 'Item Choice',
name: 'itemChoice',
values: [
{
displayName: 'Email',
name: 'email',
type: 'string',
placeholder: 'name@email.com',
default: '',
},
],
},
],
};

View file

@ -0,0 +1,163 @@
import type { INodeProperties } from 'n8n-workflow';
import { taskPresend } from '../GenericFunctions';
import { getAdditionalOptions, getId, showFor } from './utils';
const displayOpts = showFor(['task']);
const displayFor = {
resource: displayOpts(),
createOrUpdate: displayOpts(['create', 'update']),
id: displayOpts(['delete', 'find', 'update']),
};
const taskOperations: INodeProperties = {
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: displayFor.resource.displayOptions,
noDataExpression: true,
default: 'find',
options: [
{
name: 'Create',
value: 'create',
description: 'Create a task',
action: 'Create a task',
routing: {
send: { preSend: [taskPresend] },
request: {
method: 'POST',
url: '/task',
},
},
},
{
name: 'Delete',
value: 'delete',
description: 'Delete a task',
action: 'Delete a task',
routing: {
request: {
method: 'DELETE',
url: '=/task',
},
},
},
{
name: 'Find',
value: 'find',
description: 'Find a task',
action: 'Find a task',
routing: {
request: {
method: 'GET',
url: '=/task/{{$parameter["id"]}}',
},
},
},
{
name: 'Update',
value: 'update',
description: 'Update a task',
action: 'Update a task',
routing: {
send: { preSend: [taskPresend] },
request: {
method: 'PUT',
url: '=/task/{{$parameter["id"]}}',
},
},
},
],
};
const additionalOptions: INodeProperties[] = [
{
displayName: 'Name',
name: 'name',
description: 'The name of the task',
type: 'string',
default: '',
},
{
displayName: 'Body',
name: 'body',
description: 'The body of the task',
type: 'string',
typeOptions: {
rows: 4,
},
default: '',
},
{
displayName: 'Status',
name: 'status',
description: 'The status of the task',
type: 'string',
default: '',
},
{
displayName: 'Members',
name: 'members',
description:
'Members associated with the task. Each element in the array is the ID of the member.',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
displayName: 'Item Choice',
name: 'itemChoice',
values: [
{
displayName: 'Member',
name: 'member',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Activities',
name: 'activities',
description:
'Activities associated with the task. Each element in the array is the ID of the activity.',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
displayName: 'Item Choice',
name: 'itemChoice',
values: [
{
displayName: 'Activity',
name: 'activity',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Assigneess',
name: 'assigneess',
description: 'Users assigned with the task. Each element in the array is the ID of the user.',
type: 'string',
default: '',
},
];
const taskFields: INodeProperties[] = [
Object.assign(getId(), { description: 'The ID of the task' }, displayFor.id),
Object.assign({}, getAdditionalOptions(additionalOptions), displayFor.createOrUpdate),
];
export { taskOperations, taskFields };

View file

@ -0,0 +1,57 @@
import type { INodeProperties } from 'n8n-workflow';
export const showFor =
(resources: string[]) =>
(operations?: string[]): Partial<INodeProperties> => {
return operations !== undefined
? {
displayOptions: {
show: {
resource: resources,
operation: operations,
},
},
}
: {
displayOptions: {
show: {
resource: resources,
},
},
};
};
export const mapWith =
<T>(...objects: Array<Partial<T>>) =>
(item: Partial<T>) =>
Object.assign({}, item, ...objects);
export const getId = (): INodeProperties => ({
displayName: 'ID',
name: 'id',
type: 'string',
required: true,
default: '',
routing: {
send: {
type: 'query',
property: 'ids[]',
},
},
});
export const getAdditionalOptions = (fields: INodeProperties[]): INodeProperties => {
return {
displayName: 'Additional Options',
name: 'additionalOptions',
type: 'collection',
displayOptions: {
show: {
operation: ['getAll'],
},
},
default: {},
placeholder: 'Add Option',
options: fields,
};
};

File diff suppressed because it is too large Load diff

View file

@ -9,10 +9,10 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow';
import { extractId, googleApiRequest, googleApiRequestAllItems } from './GenericFunctions'; import { extractId, googleApiRequest, googleApiRequestAllItems } from './v1/GenericFunctions';
import moment from 'moment'; import moment from 'moment';
import { fileSearch, folderSearch } from './SearchFunctions'; import { fileSearch, folderSearch } from './v1/SearchFunctions';
export class GoogleDriveTrigger implements INodeType { export class GoogleDriveTrigger implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {

View file

@ -0,0 +1,78 @@
import nock from 'nock';
import * as create from '../../../../v2/actions/drive/create.operation';
import * as transport from '../../../../v2/transport';
import { createMockExecuteFunction, driveNode } from '../helpers';
jest.mock('../../../../v2/transport', () => {
const originalModule = jest.requireActual('../../../../v2/transport');
return {
...originalModule,
googleApiRequest: jest.fn(async function () {
return {};
}),
};
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports
import * as uuid from 'uuid';
jest.mock('uuid', () => {
const originalModule = jest.requireActual('uuid');
return {
...originalModule,
v4: jest.fn(function () {
return '430c0ca1-2498-472c-9d43-da0163839823';
}),
};
});
describe('test GoogleDriveV2: drive create', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
it('shuold be called with', async () => {
const nodeParameters = {
resource: 'drive',
name: 'newDrive',
options: {
capabilities: {
canComment: true,
canRename: true,
canTrashChildren: true,
},
colorRgb: '#451AD3',
hidden: false,
restrictions: {
driveMembersOnly: true,
},
},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await create.execute.call(fakeExecuteFunction, 0);
expect(transport.googleApiRequest).toBeCalledTimes(1);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'POST',
'/drive/v3/drives',
{
capabilities: { canComment: true, canRename: true, canTrashChildren: true },
colorRgb: '#451AD3',
hidden: false,
name: 'newDrive',
restrictions: { driveMembersOnly: true },
},
{ requestId: '430c0ca1-2498-472c-9d43-da0163839823' },
);
});
});

View file

@ -0,0 +1,50 @@
import nock from 'nock';
import * as deleteDrive from '../../../../v2/actions/drive/deleteDrive.operation';
import * as transport from '../../../../v2/transport';
import { createMockExecuteFunction, driveNode } from '../helpers';
jest.mock('../../../../v2/transport', () => {
const originalModule = jest.requireActual('../../../../v2/transport');
return {
...originalModule,
googleApiRequest: jest.fn(async function () {
return {};
}),
};
});
describe('test GoogleDriveV2: drive deleteDrive', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
it('shuold be called with', async () => {
const nodeParameters = {
resource: 'drive',
operation: 'deleteDrive',
driveId: {
__rl: true,
value: 'driveIDxxxxxx',
mode: 'id',
},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await deleteDrive.execute.call(fakeExecuteFunction, 0);
expect(transport.googleApiRequest).toBeCalledTimes(1);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'DELETE',
'/drive/v3/drives/driveIDxxxxxx',
);
});
});

View file

@ -0,0 +1,55 @@
import nock from 'nock';
import * as get from '../../../../v2/actions/drive/get.operation';
import * as transport from '../../../../v2/transport';
import { createMockExecuteFunction, driveNode } from '../helpers';
jest.mock('../../../../v2/transport', () => {
const originalModule = jest.requireActual('../../../../v2/transport');
return {
...originalModule,
googleApiRequest: jest.fn(async function () {
return {};
}),
};
});
describe('test GoogleDriveV2: drive get', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
it('shuold be called with', async () => {
const nodeParameters = {
resource: 'drive',
operation: 'get',
driveId: {
__rl: true,
value: 'driveIDxxxxxx',
mode: 'id',
},
options: {
useDomainAdminAccess: true,
},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await get.execute.call(fakeExecuteFunction, 0);
expect(transport.googleApiRequest).toBeCalledTimes(1);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'GET',
'/drive/v3/drives/driveIDxxxxxx',
{},
{ useDomainAdminAccess: true },
);
});
});

View file

@ -0,0 +1,78 @@
import nock from 'nock';
import * as list from '../../../../v2/actions/drive/list.operation';
import * as transport from '../../../../v2/transport';
import { createMockExecuteFunction, driveNode } from '../helpers';
jest.mock('../../../../v2/transport', () => {
const originalModule = jest.requireActual('../../../../v2/transport');
return {
...originalModule,
googleApiRequest: jest.fn(async function (method: string) {
if (method === 'GET') {
return {};
}
}),
googleApiRequestAllItems: jest.fn(async function (method: string) {
if (method === 'GET') {
return {};
}
}),
};
});
describe('test GoogleDriveV2: drive list', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
it('shuold be called with limit', async () => {
const nodeParameters = {
resource: 'drive',
operation: 'list',
limit: 20,
options: {},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await list.execute.call(fakeExecuteFunction, 0);
expect(transport.googleApiRequest).toBeCalledTimes(1);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'GET',
'/drive/v3/drives',
{},
{ pageSize: 20 },
);
});
it('shuold be called with returnAll true', async () => {
const nodeParameters = {
resource: 'drive',
operation: 'list',
returnAll: true,
options: {},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await list.execute.call(fakeExecuteFunction, 0);
expect(transport.googleApiRequestAllItems).toBeCalledTimes(1);
expect(transport.googleApiRequestAllItems).toHaveBeenCalledWith(
'GET',
'drives',
'/drive/v3/drives',
{},
{},
);
});
});

View file

@ -0,0 +1,58 @@
import nock from 'nock';
import * as update from '../../../../v2/actions/drive/update.operation';
import * as transport from '../../../../v2/transport';
import { createMockExecuteFunction, driveNode } from '../helpers';
jest.mock('../../../../v2/transport', () => {
const originalModule = jest.requireActual('../../../../v2/transport');
return {
...originalModule,
googleApiRequest: jest.fn(async function () {
return {};
}),
};
});
describe('test GoogleDriveV2: drive update', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
it('shuold be called with', async () => {
const nodeParameters = {
resource: 'drive',
operation: 'update',
driveId: {
__rl: true,
value: 'sharedDriveIDxxxxx',
mode: 'id',
},
options: {
colorRgb: '#F4BEBE',
name: 'newName',
restrictions: {
driveMembersOnly: true,
},
},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await update.execute.call(fakeExecuteFunction, 0);
expect(transport.googleApiRequest).toBeCalledTimes(1);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'PATCH',
'/drive/v3/drives/sharedDriveIDxxxxx',
{ colorRgb: '#F4BEBE', name: 'newName', restrictions: { driveMembersOnly: true } },
);
});
});

View file

@ -0,0 +1,76 @@
import nock from 'nock';
import * as copy from '../../../../v2/actions/file/copy.operation';
import * as transport from '../../../../v2/transport';
import { createMockExecuteFunction, driveNode } from '../helpers';
jest.mock('../../../../v2/transport', () => {
const originalModule = jest.requireActual('../../../../v2/transport');
return {
...originalModule,
googleApiRequest: jest.fn(async function () {
return {};
}),
};
});
describe('test GoogleDriveV2: file copy', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
it('shuold be called with', async () => {
const nodeParameters = {
operation: 'copy',
fileId: {
__rl: true,
value: 'fileIDxxxxxx',
mode: 'list',
cachedResultName: 'test01.png',
cachedResultUrl: 'https://drive.google.com/file/d/fileIDxxxxxx/view?usp=drivesdk',
},
name: 'copyImage.png',
sameFolder: false,
folderId: {
__rl: true,
value: 'folderIDxxxxxx',
mode: 'list',
cachedResultName: 'testFolder 3',
cachedResultUrl: 'https://drive.google.com/drive/folders/folderIDxxxxxx',
},
options: {
copyRequiresWriterPermission: true,
description: 'image copy',
},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await copy.execute.call(fakeExecuteFunction, 0);
expect(transport.googleApiRequest).toBeCalledTimes(1);
expect(transport.googleApiRequest).toBeCalledWith(
'POST',
'/drive/v3/files/fileIDxxxxxx/copy',
{
copyRequiresWriterPermission: true,
description: 'image copy',
name: 'copyImage.png',
parents: ['folderIDxxxxxx'],
},
{
supportsAllDrives: true,
corpora: 'allDrives',
includeItemsFromAllDrives: true,
spaces: 'appDataFolder, drive',
},
);
});
});

View file

@ -0,0 +1,91 @@
import nock from 'nock';
import * as createFromText from '../../../../v2/actions/file/createFromText.operation';
import * as transport from '../../../../v2/transport';
import { createMockExecuteFunction, driveNode } from '../helpers';
jest.mock('../../../../v2/transport', () => {
const originalModule = jest.requireActual('../../../../v2/transport');
return {
...originalModule,
googleApiRequest: jest.fn(async function () {
return {};
}),
};
});
describe('test GoogleDriveV2: file createFromText', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
it('shuold be called with', async () => {
const nodeParameters = {
operation: 'createFromText',
content: 'hello drive!',
name: 'helloDrive.txt',
folderId: {
__rl: true,
value: 'folderIDxxxxxx',
mode: 'list',
cachedResultName: 'testFolder 3',
cachedResultUrl: 'https://drive.google.com/drive/folders/folderIDxxxxxx',
},
options: {
appPropertiesUi: {
appPropertyValues: [
{
key: 'appKey1',
value: 'appValue1',
},
],
},
propertiesUi: {
propertyValues: [
{
key: 'prop1',
value: 'value1',
},
{
key: 'prop2',
value: 'value2',
},
],
},
keepRevisionForever: true,
ocrLanguage: 'en',
useContentAsIndexableText: true,
},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await createFromText.execute.call(fakeExecuteFunction, 0);
expect(transport.googleApiRequest).toBeCalledTimes(1);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'POST',
'/upload/drive/v3/files',
'\n\t\t\n--XXXXXX\t\t\nContent-Type: application/json; charset=UTF-8\t\t\n\n{"name":"helloDrive.txt","parents":["folderIDxxxxxx"],"mimeType":"text/plain","properties":{"prop1":"value1","prop2":"value2"},"appProperties":{"appKey1":"appValue1"}}\t\t\n--XXXXXX\t\t\nContent-Type: text/plain\t\t\nContent-Transfer-Encoding: base64\t\t\n\nhello drive!\t\t\n--XXXXXX--',
{
corpora: 'allDrives',
includeItemsFromAllDrives: true,
keepRevisionForever: true,
ocrLanguage: 'en',
spaces: 'appDataFolder, drive',
supportsAllDrives: true,
uploadType: 'multipart',
useContentAsIndexableText: true,
},
undefined,
{ headers: { 'Content-Length': 12, 'Content-Type': 'multipart/related; boundary=XXXXXX' } },
);
});
});

View file

@ -0,0 +1,56 @@
import nock from 'nock';
import * as deleteFile from '../../../../v2/actions/file/deleteFile.operation';
import * as transport from '../../../../v2/transport';
import { createMockExecuteFunction, driveNode } from '../helpers';
jest.mock('../../../../v2/transport', () => {
const originalModule = jest.requireActual('../../../../v2/transport');
return {
...originalModule,
googleApiRequest: jest.fn(async function () {
return {};
}),
};
});
describe('test GoogleDriveV2: file deleteFile', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
it('shuold be called with', async () => {
const nodeParameters = {
operation: 'deleteFile',
fileId: {
__rl: true,
value: 'fileIDxxxxxx',
mode: 'list',
cachedResultName: 'test.txt',
cachedResultUrl: 'https://drive.google.com/file/d/fileIDxxxxxx/view?usp=drivesdk',
},
options: {
deletePermanently: true,
},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await deleteFile.execute.call(fakeExecuteFunction, 0);
expect(transport.googleApiRequest).toBeCalledTimes(1);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'DELETE',
'/drive/v3/files/fileIDxxxxxx',
undefined,
{ supportsAllDrives: true },
);
});
});

View file

@ -0,0 +1,64 @@
import nock from 'nock';
import * as download from '../../../../v2/actions/file/download.operation';
import * as transport from '../../../../v2/transport';
import { createMockExecuteFunction, driveNode } from '../helpers';
jest.mock('../../../../v2/transport', () => {
const originalModule = jest.requireActual('../../../../v2/transport');
return {
...originalModule,
googleApiRequest: jest.fn(async function () {
return {};
}),
};
});
describe('test GoogleDriveV2: file download', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
it('shuold be called with', async () => {
const nodeParameters = {
operation: 'deleteFile',
fileId: {
__rl: true,
value: 'fileIDxxxxxx',
mode: 'list',
cachedResultName: 'test.txt',
cachedResultUrl: 'https://drive.google.com/file/d/fileIDxxxxxx/view?usp=drivesdk',
},
options: {
deletePermanently: true,
},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await download.execute.call(fakeExecuteFunction, 0, { json: {} });
expect(transport.googleApiRequest).toBeCalledTimes(2);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'GET',
'/drive/v3/files/fileIDxxxxxx',
{},
{ fields: 'mimeType,name', supportsTeamDrives: true },
);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'GET',
'/drive/v3/files/fileIDxxxxxx',
{},
{ alt: 'media' },
undefined,
{ encoding: null, json: false, resolveWithFullResponse: true, useStream: true },
);
});
});

View file

@ -0,0 +1,84 @@
import nock from 'nock';
import * as move from '../../../../v2/actions/file/move.operation';
import * as transport from '../../../../v2/transport';
import { createMockExecuteFunction, driveNode } from '../helpers';
jest.mock('../../../../v2/transport', () => {
const originalModule = jest.requireActual('../../../../v2/transport');
return {
...originalModule,
googleApiRequest: jest.fn(async function (method: string) {
if (method === 'GET') {
return {
parents: ['parentFolderIDxxxxxx'],
};
}
return {};
}),
};
});
describe('test GoogleDriveV2: file move', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
it('shuold be called with', async () => {
const nodeParameters = {
operation: 'move',
fileId: {
__rl: true,
value: 'fileIDxxxxxx',
mode: 'list',
cachedResultName: 'test.txt',
cachedResultUrl: 'https://drive.google.com/file/d/fileIDxxxxxx/view?usp=drivesdk',
},
folderId: {
__rl: true,
value: 'folderIDxxxxxx',
mode: 'list',
cachedResultName: 'testFolder1',
cachedResultUrl: 'https://drive.google.com/drive/folders/folderIDxxxxxx',
},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await move.execute.call(fakeExecuteFunction, 0);
expect(transport.googleApiRequest).toBeCalledTimes(2);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'GET',
'/drive/v3/files/fileIDxxxxxx',
undefined,
{
corpora: 'allDrives',
fields: 'parents',
includeItemsFromAllDrives: true,
spaces: 'appDataFolder, drive',
supportsAllDrives: true,
},
);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'PATCH',
'/drive/v3/files/fileIDxxxxxx',
undefined,
{
addParents: 'folderIDxxxxxx',
removeParents: 'parentFolderIDxxxxxx',
corpora: 'allDrives',
includeItemsFromAllDrives: true,
spaces: 'appDataFolder, drive',
supportsAllDrives: true,
},
);
});
});

View file

@ -0,0 +1,74 @@
import nock from 'nock';
import * as share from '../../../../v2/actions/file/share.operation';
import * as transport from '../../../../v2/transport';
import { createMockExecuteFunction, driveNode } from '../helpers';
jest.mock('../../../../v2/transport', () => {
const originalModule = jest.requireActual('../../../../v2/transport');
return {
...originalModule,
googleApiRequest: jest.fn(async function () {
return {};
}),
};
});
describe('test GoogleDriveV2: file share', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
it('shuold be called with', async () => {
const nodeParameters = {
operation: 'share',
fileId: {
__rl: true,
value: 'fileIDxxxxxx',
mode: 'list',
cachedResultName: 'test.txt',
cachedResultUrl: 'https://drive.google.com/file/d/fileIDxxxxxx/view?usp=drivesdk',
},
permissionsUi: {
permissionsValues: {
role: 'owner',
type: 'user',
emailAddress: 'user@gmail.com',
},
},
options: {
emailMessage: 'some message',
moveToNewOwnersRoot: true,
sendNotificationEmail: true,
transferOwnership: true,
useDomainAdminAccess: true,
},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await share.execute.call(fakeExecuteFunction, 0);
expect(transport.googleApiRequest).toBeCalledTimes(1);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'POST',
'/drive/v3/files/fileIDxxxxxx/permissions',
{ emailAddress: 'user@gmail.com', role: 'owner', type: 'user' },
{
emailMessage: 'some message',
moveToNewOwnersRoot: true,
sendNotificationEmail: true,
supportsAllDrives: true,
transferOwnership: true,
useDomainAdminAccess: true,
},
);
});
});

View file

@ -0,0 +1,66 @@
import nock from 'nock';
import * as update from '../../../../v2/actions/file/update.operation';
import * as transport from '../../../../v2/transport';
import { createMockExecuteFunction, driveNode } from '../helpers';
jest.mock('../../../../v2/transport', () => {
const originalModule = jest.requireActual('../../../../v2/transport');
return {
...originalModule,
googleApiRequest: jest.fn(async function () {
return {};
}),
};
});
describe('test GoogleDriveV2: file update', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
it('shuold be called with', async () => {
const nodeParameters = {
operation: 'update',
fileId: {
__rl: true,
value: 'fileIDxxxxxx',
mode: 'list',
cachedResultName: 'test.txt',
cachedResultUrl: 'https://drive.google.com/file/d/fileIDxxxxxx/view?usp=drivesdk',
},
newUpdatedFileName: 'test2.txt',
options: {
keepRevisionForever: true,
ocrLanguage: 'en',
useContentAsIndexableText: true,
fields: ['hasThumbnail', 'starred'],
},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await update.execute.call(fakeExecuteFunction, 0);
expect(transport.googleApiRequest).toBeCalledTimes(1);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'PATCH',
'/drive/v3/files/fileIDxxxxxx',
{ name: 'test2.txt' },
{
fields: 'hasThumbnail, starred',
keepRevisionForever: true,
ocrLanguage: 'en',
supportsAllDrives: true,
useContentAsIndexableText: true,
},
);
});
});

View file

@ -0,0 +1,96 @@
import nock from 'nock';
import * as upload from '../../../../v2/actions/file/upload.operation';
import * as transport from '../../../../v2/transport';
import * as utils from '../../../../v2/helpers/utils';
import { createMockExecuteFunction, driveNode } from '../helpers';
jest.mock('../../../../v2/transport', () => {
const originalModule = jest.requireActual('../../../../v2/transport');
return {
...originalModule,
googleApiRequest: jest.fn(async function (method: string) {
if (method === 'POST') {
return {
headers: { location: 'someLocation' },
};
}
return {};
}),
};
});
jest.mock('../../../../v2/helpers/utils', () => {
const originalModule = jest.requireActual('../../../../v2/helpers/utils');
return {
...originalModule,
getItemBinaryData: jest.fn(async function () {
return {
contentLength: '123',
fileContent: 'Hello Drive!',
originalFilename: 'original.txt',
mimeType: 'text/plain',
};
}),
};
});
describe('test GoogleDriveV2: file upload', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
jest.unmock('../../../../v2/helpers/utils');
});
it('shuold be called with', async () => {
const nodeParameters = {
name: 'newFile.txt',
folderId: {
__rl: true,
value: 'folderIDxxxxxx',
mode: 'list',
cachedResultName: 'testFolder 3',
cachedResultUrl: 'https://drive.google.com/drive/folders/folderIDxxxxxx',
},
options: {
simplifyOutput: true,
},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await upload.execute.call(fakeExecuteFunction, 0);
expect(transport.googleApiRequest).toBeCalledTimes(2);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'POST',
'/upload/drive/v3/files',
undefined,
{ uploadType: 'resumable' },
undefined,
{ resolveWithFullResponse: true },
);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'PATCH',
'/drive/v3/files/undefined',
{ mimeType: 'text/plain', name: 'newFile.txt', originalFilename: 'original.txt' },
{
addParents: 'folderIDxxxxxx',
supportsAllDrives: true,
corpora: 'allDrives',
includeItemsFromAllDrives: true,
spaces: 'appDataFolder, drive',
},
);
expect(utils.getItemBinaryData).toBeCalledTimes(1);
expect(utils.getItemBinaryData).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,119 @@
import nock from 'nock';
import * as search from '../../../../v2/actions/fileFolder/search.operation';
import * as transport from '../../../../v2/transport';
import { createMockExecuteFunction, driveNode } from '../helpers';
jest.mock('../../../../v2/transport', () => {
const originalModule = jest.requireActual('../../../../v2/transport');
return {
...originalModule,
googleApiRequest: jest.fn(async function (method: string) {
if (method === 'GET') {
return {};
}
}),
googleApiRequestAllItems: jest.fn(async function (method: string) {
if (method === 'GET') {
return {};
}
}),
};
});
describe('test GoogleDriveV2: fileFolder search', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
it('returnAll = false', async () => {
const nodeParameters = {
searchMethod: 'name',
resource: 'fileFolder',
queryString: 'test',
returnAll: false,
limit: 2,
filter: {
whatToSearch: 'files',
fileTypes: ['application/vnd.google-apps.document'],
},
options: {
fields: ['id', 'name', 'starred', 'version'],
},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await search.execute.call(fakeExecuteFunction, 0);
expect(transport.googleApiRequest).toBeCalledTimes(1);
expect(transport.googleApiRequest).toBeCalledWith('GET', '/drive/v3/files', undefined, {
corpora: 'allDrives',
fields: 'nextPageToken, files(id, name, starred, version)',
includeItemsFromAllDrives: true,
pageSize: 2,
q: "name contains 'test' and mimeType != 'application/vnd.google-apps.folder' and trashed = false and (mimeType = 'application/vnd.google-apps.document')",
spaces: 'appDataFolder, drive',
supportsAllDrives: true,
});
});
it('returnAll = true', async () => {
const nodeParameters = {
resource: 'fileFolder',
searchMethod: 'query',
queryString: 'test',
returnAll: true,
filter: {
driveId: {
__rl: true,
value: 'driveID000000123',
mode: 'list',
cachedResultName: 'sharedDrive',
cachedResultUrl: 'https://drive.google.com/drive/folders/driveID000000123',
},
folderId: {
__rl: true,
value: 'folderID000000123',
mode: 'list',
cachedResultName: 'testFolder 3',
cachedResultUrl: 'https://drive.google.com/drive/folders/folderID000000123',
},
whatToSearch: 'all',
fileTypes: ['*'],
includeTrashed: true,
},
options: {
fields: ['permissions', 'mimeType'],
},
};
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
await search.execute.call(fakeExecuteFunction, 0);
expect(transport.googleApiRequestAllItems).toBeCalledTimes(1);
expect(transport.googleApiRequestAllItems).toBeCalledWith(
'GET',
'files',
'/drive/v3/files',
{},
{
corpora: 'drive',
driveId: 'driveID000000123',
fields: 'nextPageToken, files(permissions, mimeType)',
includeItemsFromAllDrives: true,
q: "test and 'folderID000000123' in parents",
spaces: 'appDataFolder, drive',
supportsAllDrives: true,
},
);
});
});

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