diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index be09ec0353107..c305a41c25d24 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -15,6 +15,7 @@ import { ITelemetrySettings, ITelemetryTrackProperties, IWorkflowBase as IWorkflowBaseWorkflow, + PinData, Workflow, WorkflowExecuteMode, } from 'n8n-workflow'; @@ -646,6 +647,7 @@ export interface IWorkflowExecutionDataProcess { executionMode: WorkflowExecuteMode; executionData?: IRunExecutionData; runData?: IRunData; + pinData?: PinData; retryOf?: number | string; sessionId?: string; startNodes?: string[]; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index ec677a713fbcf..9369b14206332 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -74,6 +74,7 @@ import { IWorkflowBase, LoggerProxy, NodeHelpers, + PinData, WebhookHttpMethod, Workflow, WorkflowExecuteMode, @@ -129,6 +130,7 @@ import { WorkflowRunner, getCredentialForUser, getCredentialWithoutUser, + IWorkflowDb, } from '.'; import config from '../config'; @@ -156,9 +158,9 @@ import type { } from './requests'; import { DEFAULT_EXECUTIONS_GET_ALL_LIMIT, validateEntity } from './GenericHelpers'; import { ExecutionEntity } from './databases/entities/ExecutionEntity'; -import { SharedWorkflow } from './databases/entities/SharedWorkflow'; import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from './constants'; import { credentialsController } from './api/credentials.api'; +import { workflowsController } from './api/workflows.api'; import { oauth2CredentialController } from './api/oauth2Credential.api'; import { getInstanceBaseUrl, @@ -166,6 +168,7 @@ import { isUserManagementEnabled, } from './UserManagement/UserManagementHelper'; import { loadPublicApiVersions } from './PublicApi'; +import { SharedWorkflow } from './databases/entities/SharedWorkflow'; require('body-parser-xml')(bodyParser); @@ -754,74 +757,6 @@ class App { // Workflow // ---------------------------------------- - // Creates a new workflow - this.app.post( - `/${this.restEndpoint}/workflows`, - ResponseHelper.send(async (req: WorkflowRequest.Create) => { - delete req.body.id; // delete if sent - - const newWorkflow = new WorkflowEntity(); - - Object.assign(newWorkflow, req.body); - - await validateEntity(newWorkflow); - - await this.externalHooks.run('workflow.create', [newWorkflow]); - - const { tags: tagIds } = req.body; - - if (tagIds?.length && !config.getEnv('workflowTagsDisabled')) { - newWorkflow.tags = await Db.collections.Tag.findByIds(tagIds, { - select: ['id', 'name'], - }); - } - - await WorkflowHelpers.replaceInvalidCredentials(newWorkflow); - - let savedWorkflow: undefined | WorkflowEntity; - - await getConnection().transaction(async (transactionManager) => { - savedWorkflow = await transactionManager.save(newWorkflow); - - const role = await Db.collections.Role.findOneOrFail({ - name: 'owner', - scope: 'workflow', - }); - - const newSharedWorkflow = new SharedWorkflow(); - - Object.assign(newSharedWorkflow, { - role, - user: req.user, - workflow: savedWorkflow, - }); - - await transactionManager.save(newSharedWorkflow); - }); - - if (!savedWorkflow) { - LoggerProxy.error('Failed to create workflow', { userId: req.user.id }); - throw new ResponseHelper.ResponseError('Failed to save workflow'); - } - - if (tagIds && !config.getEnv('workflowTagsDisabled')) { - savedWorkflow.tags = TagHelpers.sortByRequestOrder(savedWorkflow.tags, { - requestOrder: tagIds, - }); - } - - await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]); - void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow, false); - - const { id, ...rest } = savedWorkflow; - - return { - id: id.toString(), - ...rest, - }; - }), - ); - // Reads and returns workflow data from an URL this.app.get( `/${this.restEndpoint}/workflows/from-url`, @@ -947,50 +882,6 @@ class App { }), ); - // Returns a specific workflow - this.app.get( - `/${this.restEndpoint}/workflows/:id`, - ResponseHelper.send(async (req: WorkflowRequest.Get) => { - const { id: workflowId } = req.params; - - let relations = ['workflow', 'workflow.tags']; - - if (config.getEnv('workflowTagsDisabled')) { - relations = relations.filter((relation) => relation !== 'workflow.tags'); - } - - const shared = await Db.collections.SharedWorkflow.findOne({ - relations, - where: whereClause({ - user: req.user, - entityType: 'workflow', - entityId: workflowId, - }), - }); - - if (!shared) { - LoggerProxy.info('User attempted to access a workflow without permissions', { - workflowId, - userId: req.user.id, - }); - throw new ResponseHelper.ResponseError( - `Workflow with ID "${workflowId}" could not be found.`, - undefined, - 404, - ); - } - - const { - workflow: { id, ...rest }, - } = shared; - - return { - id: id.toString(), - ...rest, - }; - }), - ); - // Updates an existing workflow this.app.patch( `/${this.restEndpoint}/workflows/:id`, @@ -1189,6 +1080,7 @@ class App { ): Promise => { const { workflowData } = req.body; const { runData } = req.body; + const { pinData } = req.body; const { startNodes } = req.body; const { destinationNode } = req.body; const executionMode = 'manual'; @@ -1196,12 +1088,15 @@ class App { const sessionId = GenericHelpers.getSessionId(req); + const pinnedTrigger = findFirstPinnedTrigger(workflowData, pinData); + // If webhooks nodes exist and are active we have to wait for till we receive a call if ( - runData === undefined || - startNodes === undefined || - startNodes.length === 0 || - destinationNode === undefined + pinnedTrigger === undefined && + (runData === undefined || + startNodes === undefined || + startNodes.length === 0 || + destinationNode === undefined) ) { const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id); const nodeTypes = NodeTypes(); @@ -1239,11 +1134,17 @@ class App { destinationNode, executionMode, runData, + pinData, sessionId, startNodes, workflowData, userId: req.user.id, }; + + if (pinnedTrigger) { + data.startNodes = [pinnedTrigger.name]; + } + const workflowRunner = new WorkflowRunner(); const executionId = await workflowRunner.run(data); @@ -1254,6 +1155,8 @@ class App { ), ); + this.app.use(`/${this.restEndpoint}/workflows`, workflowsController); + // Retrieves all tags, with or without usage count this.app.get( `/${this.restEndpoint}/tags`, @@ -2912,3 +2815,20 @@ function isOAuth(credType: ICredentialType) { ) ); } + +const TRIGGER_NODE_SUFFIXES = ['trigger', 'webhook']; + +const isTrigger = (str: string) => + TRIGGER_NODE_SUFFIXES.some((suffix) => str.toLowerCase().includes(suffix)); + +function findFirstPinnedTrigger(workflow: IWorkflowDb, pinData?: PinData) { + if (!pinData) return; + + const firstPinnedTriggerName = Object.keys(pinData).find(isTrigger); + + if (!firstPinnedTriggerName) return; + + return workflow.nodes.find( + ({ type, name }) => isTrigger(type) && name === firstPinnedTriggerName, + ); +} diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index 2083acb510876..e737e20501a62 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -485,6 +485,10 @@ export async function executeWebhook( return undefined; } + if (workflowData.pinData) { + data.data.resultData.pinData = workflowData.pinData; + } + const returnData = WorkflowHelpers.getDataLastExecutedNodeData(data); if (data.data.resultData.error || returnData?.error !== undefined) { if (!didSendResponse) { diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index e625c6c591761..e7af8c1b037ad 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -50,7 +50,7 @@ const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); * @returns {(ITaskData | undefined)} */ export function getDataLastExecutedNodeData(inputData: IRun): ITaskData | undefined { - const { runData } = inputData.data.resultData; + const { runData, pinData = {} } = inputData.data.resultData; const { lastNodeExecuted } = inputData.data.resultData; if (lastNodeExecuted === undefined) { @@ -61,7 +61,26 @@ export function getDataLastExecutedNodeData(inputData: IRun): ITaskData | undefi return undefined; } - return runData[lastNodeExecuted][runData[lastNodeExecuted].length - 1]; + const lastNodeRunData = runData[lastNodeExecuted][runData[lastNodeExecuted].length - 1]; + + let lastNodePinData = pinData[lastNodeExecuted]; + + if (lastNodePinData) { + if (!Array.isArray(lastNodePinData)) lastNodePinData = [lastNodePinData]; + + const itemsPerRun = lastNodePinData.map((item, index) => { + return { json: item, pairedItem: { item: index } }; + }); + + return { + startTime: 0, + executionTime: 0, + data: { main: [itemsPerRun] }, + source: lastNodeRunData.source, + }; + } + + return lastNodeRunData; } /** diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index cc3b0d7fcabf2..9cce04ffa4063 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -310,7 +310,12 @@ export class WorkflowRunner { // Can execute without webhook so go on const workflowExecute = new WorkflowExecute(additionalData, data.executionMode); - workflowExecution = workflowExecute.run(workflow, undefined, data.destinationNode); + workflowExecution = workflowExecute.run( + workflow, + undefined, + data.destinationNode, + data.pinData, + ); } else { Logger.debug(`Execution ID ${executionId} is a partial execution.`, { executionId }); // Execute only the nodes between start and destination nodes @@ -320,6 +325,7 @@ export class WorkflowRunner { data.runData, data.startNodes, data.destinationNode, + data.pinData, ); } diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index d77f5d329fdec..468ef57383a30 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -345,9 +345,22 @@ export class WorkflowRunnerProcess { ) { // Execute all nodes + let startNode; + if ( + this.data.startNodes?.length === 1 && + Object.keys(this.data.pinData ?? {}).includes(this.data.startNodes[0]) + ) { + startNode = this.workflow.getNode(this.data.startNodes[0]) ?? undefined; + } + // Can execute without webhook so go on this.workflowExecute = new WorkflowExecute(additionalData, this.data.executionMode); - return this.workflowExecute.run(this.workflow, undefined, this.data.destinationNode); + return this.workflowExecute.run( + this.workflow, + startNode, + this.data.destinationNode, + this.data.pinData, + ); } // Execute only the nodes between start and destination nodes this.workflowExecute = new WorkflowExecute(additionalData, this.data.executionMode); @@ -356,6 +369,7 @@ export class WorkflowRunnerProcess { this.data.runData, this.data.startNodes, this.data.destinationNode, + this.data.pinData, ); } diff --git a/packages/cli/src/api/workflows.api.ts b/packages/cli/src/api/workflows.api.ts new file mode 100644 index 0000000000000..3afd63f8b7f24 --- /dev/null +++ b/packages/cli/src/api/workflows.api.ts @@ -0,0 +1,134 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable no-param-reassign */ +/* eslint-disable import/no-cycle */ + +import express from 'express'; +import { LoggerProxy } from 'n8n-workflow'; + +import { Db, ResponseHelper, whereClause, WorkflowHelpers } from '..'; +import config from '../../config'; +import * as TagHelpers from '../TagHelpers'; +import { SharedWorkflow } from '../databases/entities/SharedWorkflow'; +import { WorkflowEntity } from '../databases/entities/WorkflowEntity'; +import { validateEntity } from '../GenericHelpers'; +import { InternalHooksManager } from '../InternalHooksManager'; +import { externalHooks } from '../Server'; +import type { WorkflowRequest } from '../requests'; + +export const workflowsController = express.Router(); + +/** + * POST /workflows + */ +workflowsController.post( + '/', + ResponseHelper.send(async (req: WorkflowRequest.Create) => { + delete req.body.id; // delete if sent + + const newWorkflow = new WorkflowEntity(); + + Object.assign(newWorkflow, req.body); + + await validateEntity(newWorkflow); + + await externalHooks.run('workflow.create', [newWorkflow]); + + const { tags: tagIds } = req.body; + + if (tagIds?.length && !config.getEnv('workflowTagsDisabled')) { + newWorkflow.tags = await Db.collections.Tag.findByIds(tagIds, { + select: ['id', 'name'], + }); + } + + await WorkflowHelpers.replaceInvalidCredentials(newWorkflow); + + let savedWorkflow: undefined | WorkflowEntity; + + await Db.transaction(async (transactionManager) => { + savedWorkflow = await transactionManager.save(newWorkflow); + + const role = await Db.collections.Role.findOneOrFail({ + name: 'owner', + scope: 'workflow', + }); + + const newSharedWorkflow = new SharedWorkflow(); + + Object.assign(newSharedWorkflow, { + role, + user: req.user, + workflow: savedWorkflow, + }); + + await transactionManager.save(newSharedWorkflow); + }); + + if (!savedWorkflow) { + LoggerProxy.error('Failed to create workflow', { userId: req.user.id }); + throw new ResponseHelper.ResponseError('Failed to save workflow'); + } + + if (tagIds && !config.getEnv('workflowTagsDisabled')) { + savedWorkflow.tags = TagHelpers.sortByRequestOrder(savedWorkflow.tags, { + requestOrder: tagIds, + }); + } + + await externalHooks.run('workflow.afterCreate', [savedWorkflow]); + void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow, false); + + const { id, ...rest } = savedWorkflow; + + return { + id: id.toString(), + ...rest, + }; + }), +); + +/** + * GET /workflows/:id + */ +workflowsController.get( + '/:id', + ResponseHelper.send(async (req: WorkflowRequest.Get) => { + const { id: workflowId } = req.params; + + let relations = ['workflow', 'workflow.tags']; + + if (config.getEnv('workflowTagsDisabled')) { + relations = relations.filter((relation) => relation !== 'workflow.tags'); + } + + const shared = await Db.collections.SharedWorkflow.findOne({ + relations, + where: whereClause({ + user: req.user, + entityType: 'workflow', + entityId: workflowId, + }), + }); + + if (!shared) { + LoggerProxy.info('User attempted to access a workflow without permissions', { + workflowId, + userId: req.user.id, + }); + throw new ResponseHelper.ResponseError( + `Workflow with ID "${workflowId}" could not be found.`, + undefined, + 404, + ); + } + + const { + workflow: { id, ...rest }, + } = shared; + + return { + id: id.toString(), + ...rest, + }; + }), +); diff --git a/packages/cli/src/databases/entities/WorkflowEntity.ts b/packages/cli/src/databases/entities/WorkflowEntity.ts index 069f637737fe4..31b2b5fe70f0b 100644 --- a/packages/cli/src/databases/entities/WorkflowEntity.ts +++ b/packages/cli/src/databases/entities/WorkflowEntity.ts @@ -2,7 +2,7 @@ /* eslint-disable import/no-cycle */ import { Length } from 'class-validator'; -import { IConnections, IDataObject, INode, IWorkflowSettings } from 'n8n-workflow'; +import { IConnections, IDataObject, INode, IWorkflowSettings, PinData } from 'n8n-workflow'; import { BeforeUpdate, @@ -22,7 +22,7 @@ import * as config from '../../../config'; import { DatabaseType, IWorkflowDb } from '../..'; import { TagEntity } from './TagEntity'; import { SharedWorkflow } from './SharedWorkflow'; -import { objectRetriever } from '../utils/transformers'; +import { objectRetriever, serializer } from '../utils/transformers'; function resolveDataType(dataType: string) { const dbType = config.getEnv('database.type'); @@ -117,6 +117,13 @@ export class WorkflowEntity implements IWorkflowDb { @OneToMany(() => SharedWorkflow, (sharedWorkflow) => sharedWorkflow.workflow) shared: SharedWorkflow[]; + @Column({ + type: config.getEnv('database.type') === 'sqlite' ? 'text' : 'json', + nullable: true, + transformer: serializer, + }) + pinData: PinData; + @BeforeUpdate() setUpdateDate() { this.updatedAt = new Date(); diff --git a/packages/cli/src/databases/migrations/mysqldb/1654090101303-IntroducePinData.ts b/packages/cli/src/databases/migrations/mysqldb/1654090101303-IntroducePinData.ts new file mode 100644 index 0000000000000..c69a0848bea43 --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1654090101303-IntroducePinData.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; +import config from '../../../../config'; + +export class IntroducePinData1654090101303 implements MigrationInterface { + name = 'IntroducePinData1654090101303'; + + async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}workflow_entity\` ADD \`pinData\` json`, + ); + + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query(`ALTER TABLE \`${tablePrefix}workflow_entity\` DROP COLUMN \`pinData\``); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 31993d41cd603..a793ea3828a11 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -15,6 +15,7 @@ import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserMan import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail'; import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings'; import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn'; +import { IntroducePinData1654090101303 } from './1654090101303-IntroducePinData'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -34,4 +35,5 @@ export const mysqlMigrations = [ LowerCaseUserEmail1648740597343, AddUserSettings1652367743993, AddAPIKeyColumn1652905585850, + IntroducePinData1654090101303, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/1654090467022-IntroducePinData.ts b/packages/cli/src/databases/migrations/postgresdb/1654090467022-IntroducePinData.ts new file mode 100644 index 0000000000000..6b28194407265 --- /dev/null +++ b/packages/cli/src/databases/migrations/postgresdb/1654090467022-IntroducePinData.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; +import config from '../../../../config'; + +export class IntroducePinData1654090467022 implements MigrationInterface { + name = 'IntroducePinData1654090467022'; + + async up(queryRunner: QueryRunner) { + logMigrationStart(this.name); + + const schema = config.getEnv('database.postgresdb.schema'); + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query(`SET search_path TO ${schema}`); + + await queryRunner.query( + `ALTER TABLE ${schema}.${tablePrefix}workflow_entity ADD "pinData" json`, + ); + + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner) { + const schema = config.getEnv('database.postgresdb.schema'); + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query(`SET search_path TO ${schema}`); + + await queryRunner.query( + `ALTER TABLE ${schema}.${tablePrefix}workflow_entity DROP COLUMN "pinData"`, + ); + } +} diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 30cc17b873cd8..c8e689fb862c9 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -13,6 +13,7 @@ import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserMan import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail'; import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings'; import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn'; +import { IntroducePinData1654090467022 } from './1654090467022-IntroducePinData'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -30,4 +31,5 @@ export const postgresMigrations = [ LowerCaseUserEmail1648740597343, AddUserSettings1652367743993, AddAPIKeyColumn1652905585850, + IntroducePinData1654090467022, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1654089251344-IntroducePinData.ts b/packages/cli/src/databases/migrations/sqlite/1654089251344-IntroducePinData.ts new file mode 100644 index 0000000000000..d36bca491baa6 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1654089251344-IntroducePinData.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; +import config from '../../../../config'; + +export class IntroducePinData1654089251344 implements MigrationInterface { + name = 'IntroducePinData1654089251344'; + + async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}workflow_entity\` ADD COLUMN "pinData" text`, + ); + + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query(`ALTER TABLE \`${tablePrefix}workflow_entity\` RENAME TO "temporary_workflow_entity"`); + await queryRunner.query( + `CREATE TABLE \`${tablePrefix}workflow_entity\` ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text NOT NULL, "connections" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL, "settings" text, "staticData" text`, + ); + await queryRunner.query( + `INSERT INTO \`${tablePrefix}workflow_entity\` ("id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData") SELECT "id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData" FROM "temporary_workflow_entity"`, + ); + await queryRunner.query(`DROP TABLE "temporary_workflow_entity"`); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 1d51552e91430..c6044c5a7fc41 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -12,6 +12,7 @@ import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserMan import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail'; import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings'; import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn'; +import { IntroducePinData1654089251344 } from './1654089251344-IntroducePinData'; const sqliteMigrations = [ InitialMigration1588102412422, @@ -28,6 +29,7 @@ const sqliteMigrations = [ LowerCaseUserEmail1648740597343, AddUserSettings1652367743993, AddAPIKeyColumn1652905585850, + IntroducePinData1654089251344, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/utils/transformers.ts b/packages/cli/src/databases/utils/transformers.ts index e3a77fc2df001..94f706a91d2d4 100644 --- a/packages/cli/src/databases/utils/transformers.ts +++ b/packages/cli/src/databases/utils/transformers.ts @@ -18,3 +18,13 @@ export const objectRetriever: ValueTransformer = { from: (value: string | object): object => typeof value === 'string' ? (JSON.parse(value) as object) : value, }; + +/** + * Transformer to store object as string and retrieve string as object. + */ +export const serializer: ValueTransformer = { + to: (value: object | string): string => + typeof value === 'object' ? JSON.stringify(value) : value, + from: (value: string | object): object => + typeof value === 'string' ? (JSON.parse(value) as object) : value, +}; diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts index 9499dc25b1f6a..f6faf097cd894 100644 --- a/packages/cli/src/requests.d.ts +++ b/packages/cli/src/requests.d.ts @@ -8,6 +8,7 @@ import { INodeCredentialTestRequest, IRunData, IWorkflowSettings, + PinData, } from 'n8n-workflow'; import { User } from './databases/entities/User'; @@ -71,6 +72,7 @@ export declare namespace WorkflowRequest { { workflowData: IWorkflowDb; runData: IRunData; + pinData: PinData; startNodes?: string[]; destinationNode?: string; } diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts index e676b9c16461c..9d01a927f234e 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -38,7 +38,10 @@ beforeAll(async () => { }); beforeEach(async () => { - await testDb.truncate(['SharedWorkflow', 'User', 'Workflow'], testDbName); + await testDb.truncate( + ['SharedCredentials', 'SharedWorkflow', 'Tag', 'User', 'Workflow', 'Credentials'], + testDbName, + ); config.set('userManagement.disabled', false); config.set('userManagement.isInstanceOwnerSetUp', true); diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index 3d7cc614021bc..e28a571e134e6 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -199,6 +199,7 @@ export async function truncate(collections: Array, testDbName: s const truncationPromises = collections.map((collection) => { const tableName = toTableName(collection); + Db.collections[collection].clear(); return testDb.query( `DELETE FROM ${tableName}; DELETE FROM sqlite_sequence WHERE name=${tableName};`, ); @@ -221,7 +222,6 @@ export async function truncate(collections: Array, testDbName: s } return await truncateMappingTables(dbType, collections, testDb); - // return Promise.resolve([]) } /** diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts index 1b31e598a4783..d628f7bf17888 100644 --- a/packages/cli/test/integration/shared/types.d.ts +++ b/packages/cli/test/integration/shared/types.d.ts @@ -17,6 +17,7 @@ type EndpointGroup = | 'owner' | 'passwordReset' | 'credentials' + | 'workflows' | 'publicApi'; export type CredentialPayload = { diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index cfd681187e3f8..fc1e0d5ae855f 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -46,6 +46,7 @@ import { loadPublicApiVersions } from '../../../src/PublicApi/'; import type { User } from '../../../src/databases/entities/User'; import type { ApiPath, EndpointGroup, PostgresSchemaSection, TriggerTime } from './types'; import type { N8nApp } from '../../../src/UserManagement/Interfaces'; +import { workflowsController } from '../../../src/api/workflows.api'; /** * Initialize a test server. @@ -87,16 +88,17 @@ export async function initTestServer({ if (routerEndpoints.length) { const { apiRouters } = await loadPublicApiVersions(testServer.publicApiEndpoint); - const map: Record = { - credentials: credentialsController, - publicApi: apiRouters, + const map: Record = { + credentials: { controller: credentialsController, path: 'credentials' }, + workflows: { controller: workflowsController, path: 'workflows' }, + publicApi: apiRouters }; for (const group of routerEndpoints) { if (group === 'publicApi') { testServer.app.use(...(map[group] as express.Router[])); } else { - testServer.app.use(`/${testServer.restEndpoint}/${group}`, map[group]); + testServer.app.use(`/${testServer.restEndpoint}/${map[group].path}`, map[group].controller); } } } @@ -135,10 +137,10 @@ const classifyEndpointGroups = (endpointGroups: string[]) => { const routerEndpoints: string[] = []; const functionEndpoints: string[] = []; + const ROUTER_GROUP = ['credentials', 'workflows', 'publicApi']; + endpointGroups.forEach((group) => - (group === 'credentials' || group === 'publicApi' ? routerEndpoints : functionEndpoints).push( - group, - ), + (ROUTER_GROUP.includes(group) ? routerEndpoints : functionEndpoints).push(group), ); return [routerEndpoints, functionEndpoints]; diff --git a/packages/cli/test/integration/workflows.api.test.ts b/packages/cli/test/integration/workflows.api.test.ts new file mode 100644 index 0000000000000..0ff64b06556e0 --- /dev/null +++ b/packages/cli/test/integration/workflows.api.test.ts @@ -0,0 +1,107 @@ +import express from 'express'; + +import * as utils from './shared/utils'; +import * as testDb from './shared/testDb'; +import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity'; +import type { Role } from '../../src/databases/entities/Role'; +import { PinData } from 'n8n-workflow'; + +jest.mock('../../src/telemetry'); + +let app: express.Application; +let testDbName = ''; +let globalOwnerRole: Role; + +beforeAll(async () => { + app = await utils.initTestServer({ + endpointGroups: ['workflows'], + applyAuth: true, + }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + globalOwnerRole = await testDb.getGlobalOwnerRole(); + + utils.initTestLogger(); + utils.initTestTelemetry(); +}); + +beforeEach(async () => { + await testDb.truncate(['User', 'Workflow', 'SharedWorkflow'], testDbName); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('POST /workflows should store pin data for node in workflow', async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const workflow = makeWorkflow({ withPinData: true }); + + const response = await authOwnerAgent.post('/workflows').send(workflow); + + expect(response.statusCode).toBe(200); + + const { pinData } = response.body.data as { pinData: PinData }; + + expect(pinData).toMatchObject({ Spotify: [{ myKey: 'myValue' }] }); +}); + +test('POST /workflows should set pin data to null if no pin data', async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const workflow = makeWorkflow({ withPinData: false }); + + const response = await authOwnerAgent.post('/workflows').send(workflow); + + expect(response.statusCode).toBe(200); + + const { pinData } = response.body.data as { pinData: PinData }; + + expect(pinData).toBeNull(); +}); + +test('GET /workflows/:id should return pin data', async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const workflow = makeWorkflow({ withPinData: true }); + + const workflowCreationResponse = await authOwnerAgent.post('/workflows').send(workflow); + + const { id } = workflowCreationResponse.body.data as { id: string }; + + const workflowRetrievalResponse = await authOwnerAgent.get(`/workflows/${id}`); + + expect(workflowRetrievalResponse.statusCode).toBe(200); + + const { pinData } = workflowRetrievalResponse.body.data as { pinData: PinData }; + + expect(pinData).toMatchObject({ Spotify: [{ myKey: 'myValue' }] }); +}); + +function makeWorkflow({ withPinData }: { withPinData: boolean }) { + const workflow = new WorkflowEntity(); + + workflow.name = 'My Workflow'; + workflow.active = false; + workflow.connections = {}; + workflow.nodes = [ + { + name: 'Spotify', + type: 'n8n-nodes-base.spotify', + parameters: { resource: 'track', operation: 'get', id: '123' }, + typeVersion: 1, + position: [740, 240], + }, + ]; + + if (withPinData) { + workflow.pinData = { Spotify: [{ myKey: 'myValue' }] }; + } + + return workflow; +} diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index ca2b0ecb75235..f404891890378 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -32,6 +32,7 @@ import { LoggerProxy as Logger, NodeApiError, NodeOperationError, + PinData, Workflow, WorkflowExecuteMode, WorkflowOperationError, @@ -59,6 +60,7 @@ export class WorkflowExecute { startData: {}, resultData: { runData: {}, + pinData: {}, }, executionData: { contextData: {}, @@ -82,7 +84,12 @@ export class WorkflowExecute { // PCancelable to a regular Promise and does so not allow canceling // active executions anymore // eslint-disable-next-line @typescript-eslint/promise-function-async - run(workflow: Workflow, startNode?: INode, destinationNode?: string): PCancelable { + run( + workflow: Workflow, + startNode?: INode, + destinationNode?: string, + pinData?: PinData, + ): PCancelable { // Get the nodes to start workflow execution from startNode = startNode || workflow.getStartNode(destinationNode); @@ -121,6 +128,7 @@ export class WorkflowExecute { }, resultData: { runData: {}, + pinData, }, executionData: { contextData: {}, @@ -152,6 +160,7 @@ export class WorkflowExecute { runData: IRunData, startNodes: string[], destinationNode: string, + pinData?: PinData, // @ts-ignore ): PCancelable { let incomingNodeConnections: INodeConnections | undefined; @@ -258,6 +267,7 @@ export class WorkflowExecute { }, resultData: { runData, + pinData, }, executionData: { contextData: {}, @@ -683,7 +693,13 @@ export class WorkflowExecute { destinationNode = this.runExecutionData.startData.destinationNode; } - const workflowIssues = workflow.checkReadyForExecution({ startNode, destinationNode }); + const pinDataNodeNames = Object.keys(this.runExecutionData.resultData.pinData ?? {}); + + const workflowIssues = workflow.checkReadyForExecution({ + startNode, + destinationNode, + pinDataNodeNames, + }); if (workflowIssues !== null) { throw new Error( 'The workflow has issues and can for that reason not be executed. Please fix them first.', @@ -914,24 +930,37 @@ export class WorkflowExecute { } } - Logger.debug(`Running node "${executionNode.name}" started`, { - node: executionNode.name, - workflowId: workflow.id, - }); - const runNodeData = await workflow.runNode( - executionData, - this.runExecutionData, - runIndex, - this.additionalData, - NodeExecuteFunctions, - this.mode, - ); - nodeSuccessData = runNodeData.data; + const { pinData } = this.runExecutionData.resultData; + + if (pinData && !executionNode.disabled && pinData[executionNode.name] !== undefined) { + let nodePinData = pinData[executionNode.name]; + + if (!Array.isArray(nodePinData)) nodePinData = [nodePinData]; - if (runNodeData.closeFunction) { - // Explanation why we do this can be found in n8n-workflow/Workflow.ts -> runNode - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - closeFunction = runNodeData.closeFunction(); + const itemsPerRun = nodePinData.map((item, index) => { + return { json: item, pairedItem: { item: index } }; + }); + nodeSuccessData = [itemsPerRun]; // always zeroth runIndex + } else { + Logger.debug(`Running node "${executionNode.name}" started`, { + node: executionNode.name, + workflowId: workflow.id, + }); + const runNodeData = await workflow.runNode( + executionData, + this.runExecutionData, + runIndex, + this.additionalData, + NodeExecuteFunctions, + this.mode, + ); + nodeSuccessData = runNodeData.data; + + if (runNodeData.closeFunction) { + // Explanation why we do this can be found in n8n-workflow/Workflow.ts -> runNode + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + closeFunction = runNodeData.closeFunction(); + } } Logger.debug(`Running node "${executionNode.name}" finished successfully`, { diff --git a/packages/design-system/src/components/N8nCallout/Callout.vue b/packages/design-system/src/components/N8nCallout/Callout.vue new file mode 100644 index 0000000000000..9100d38ec6f6d --- /dev/null +++ b/packages/design-system/src/components/N8nCallout/Callout.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/packages/design-system/src/components/N8nCallout/N8nCallout.stories.ts b/packages/design-system/src/components/N8nCallout/N8nCallout.stories.ts new file mode 100644 index 0000000000000..0c7a315e1973c --- /dev/null +++ b/packages/design-system/src/components/N8nCallout/N8nCallout.stories.ts @@ -0,0 +1,107 @@ +import N8nCallout from './Callout.vue'; +import { StoryFn } from '@storybook/vue'; +import N8nLink from '../N8nLink'; +import N8nText from '../N8nText'; + +export default { + title: 'Atoms/Callout', + component: N8nCallout, + argTypes: { + theme: { + control: { + type: 'select', + options: ['info', 'secondary', 'success', 'warning', 'danger', 'custom'], + }, + }, + message: { + control: { + type: 'text', + }, + }, + icon: { + control: { + type: 'text', + }, + }, + }, + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/tPpJvbrnHbP8C496cYuwyW/Node-pinning?node-id=15%3A5777', + }, + }, +}; + +const template: StoryFn = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nLink, + N8nText, + N8nCallout, + }, + template: ` + + ${args.default} + + + + `, +}); + +export const customCallout = template.bind({}); +customCallout.args = { + theme: 'custom', + icon: 'code-branch', + default: ` + + This is a callout. + + `, + actions: ` + + Do something! + + `, +}; + +export const secondaryCallout = template.bind({}); +secondaryCallout.args = { + theme: 'secondary', + icon: 'thumbtack', + default: ` + + This data is pinned. + + `, + actions: ` + + Unpin + + `, + trailingContent: ` + + Learn more + + `, +}; diff --git a/packages/design-system/src/components/N8nCallout/index.ts b/packages/design-system/src/components/N8nCallout/index.ts new file mode 100644 index 0000000000000..fea22c69f70c6 --- /dev/null +++ b/packages/design-system/src/components/N8nCallout/index.ts @@ -0,0 +1,3 @@ +import N8nCallout from './Callout.vue'; + +export default N8nCallout; diff --git a/packages/design-system/src/components/N8nIconButton/IconButton.vue b/packages/design-system/src/components/N8nIconButton/IconButton.vue index 322b8288d150e..cf1482712d248 100644 --- a/packages/design-system/src/components/N8nIconButton/IconButton.vue +++ b/packages/design-system/src/components/N8nIconButton/IconButton.vue @@ -1,5 +1,9 @@ diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts index 43a64ca9ec15e..4f7642a1da8de 100644 --- a/packages/design-system/src/components/index.ts +++ b/packages/design-system/src/components/index.ts @@ -40,6 +40,7 @@ import N8nAvatar from './N8nAvatar'; import N8nBadge from './N8nBadge'; import N8nButton from './N8nButton'; import { N8nElButton } from './N8nButton/overrides'; +import N8nCallout from './N8nCallout'; import N8nCard from './N8nCard'; import N8nFormBox from './N8nFormBox'; import N8nFormInput from './N8nFormInput'; @@ -82,6 +83,7 @@ export { N8nBadge, N8nButton, N8nElButton, + N8nCallout, N8nCard, N8nHeading, N8nFormBox, diff --git a/packages/design-system/theme/src/_tokens.scss b/packages/design-system/theme/src/_tokens.scss index f60eeae641583..8baf9c58adf02 100644 --- a/packages/design-system/theme/src/_tokens.scss +++ b/packages/design-system/theme/src/_tokens.scss @@ -72,6 +72,27 @@ var(--color-secondary-l) ); + --color-secondary-tint-1-h: 247; + --color-secondary-tint-1-s: 49%; + --color-secondary-tint-1-l: 85%; + --color-secondary-tint-1: hsl(var(--color-secondary-tint-1-h), + var(--color-secondary-tint-1-s), + var(--color-secondary-tint-1-l)); + + --color-secondary-tint-2-h: 247; + --color-secondary-tint-2-s: 49%; + --color-secondary-tint-2-l: 92%; + --color-secondary-tint-2: hsl(var(--color-secondary-tint-2-h), + var(--color-secondary-tint-2-s), + var(--color-secondary-tint-2-l)); + + --color-secondary-tint-3-h: 247; + --color-secondary-tint-3-s: 49%; + --color-secondary-tint-3-l: 95%; + --color-secondary-tint-3: hsl(var(--color-secondary-tint-3-h), + var(--color-secondary-tint-3-s), + var(--color-secondary-tint-3-l)); + --color-success-h: 150.4; --color-success-s: 60%; --color-success-l: 40.4%; diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index dcd983794e097..cda60b0f74a53 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -21,6 +21,7 @@ import { ITelemetrySettings, IWorkflowSettings as IWorkflowSettingsWorkflow, WorkflowExecuteMode, + PinData, } from 'n8n-workflow'; export * from 'n8n-design-system/src/types'; @@ -145,6 +146,7 @@ export interface INodeUi extends INode { notes?: string; issues?: INodeIssues; name: string; + pinData?: IDataObject; } export interface INodeTypesMaxCount { @@ -211,6 +213,7 @@ export interface IStartRunData { startNodes?: string[]; destinationNode?: string; runData?: IRunData; + pinData?: PinData; } export interface IRunDataUi { @@ -245,6 +248,7 @@ export interface IWorkflowData { connections: IConnections; settings?: IWorkflowSettings; tags?: string[]; + pinData?: PinData; } export interface IWorkflowDataUpdate { @@ -255,6 +259,7 @@ export interface IWorkflowDataUpdate { settings?: IWorkflowSettings; active?: boolean; tags?: ITag[] | string[]; // string[] when store or requested, ITag[] from API response + pinData?: PinData; } export interface IWorkflowTemplate { @@ -277,6 +282,7 @@ export interface IWorkflowDb { connections: IConnections; settings?: IWorkflowSettings; tags?: ITag[] | string[]; // string[] when store or requested, ITag[] from API response + pinData?: PinData; } // Identical to cli.Interfaces.ts @@ -869,6 +875,10 @@ export interface IUiState { }; output: { displayMode: IRunDataDisplayMode; + editMode: { + enabled: boolean; + value: string; + }; }; }; mainPanelPosition: number; diff --git a/packages/editor-ui/src/components/CodeEdit.vue b/packages/editor-ui/src/components/CodeEdit.vue index b23d734284b84..1b6ece36145e9 100644 --- a/packages/editor-ui/src/components/CodeEdit.vue +++ b/packages/editor-ui/src/components/CodeEdit.vue @@ -8,15 +8,12 @@ :before-close="closeDialog" >
-
+
diff --git a/packages/editor-ui/src/components/TriggerPanel.vue b/packages/editor-ui/src/components/TriggerPanel.vue index 8dcdf14c5d4ef..ca403b87fe15c 100644 --- a/packages/editor-ui/src/components/TriggerPanel.vue +++ b/packages/editor-ui/src/components/TriggerPanel.vue @@ -10,8 +10,8 @@ {{ $locale.baseText('ndv.trigger.webhookNode.listening') }} -
- +
+ {{ $locale.baseText('ndv.trigger.webhookNode.requestHint', { interpolate: { type: this.webhookHttpMethod }, @@ -33,8 +33,8 @@ {{ $locale.baseText('ndv.trigger.webhookBasedNode.listening') }} -
- +
+ {{ $locale.baseText('ndv.trigger.webhookBasedNode.serviceHint', { interpolate: { service: serviceName }, diff --git a/packages/editor-ui/src/components/forms/CodeEditor.vue b/packages/editor-ui/src/components/forms/CodeEditor.vue new file mode 100644 index 0000000000000..cbede9326ac34 --- /dev/null +++ b/packages/editor-ui/src/components/forms/CodeEditor.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/packages/editor-ui/src/components/forms/index.ts b/packages/editor-ui/src/components/forms/index.ts new file mode 100644 index 0000000000000..345b928ff05d8 --- /dev/null +++ b/packages/editor-ui/src/components/forms/index.ts @@ -0,0 +1 @@ +export { default as CodeEditor } from './CodeEditor.vue'; diff --git a/packages/editor-ui/src/components/helpers.ts b/packages/editor-ui/src/components/helpers.ts index df3429756fe93..0817ba759a737 100644 --- a/packages/editor-ui/src/components/helpers.ts +++ b/packages/editor-ui/src/components/helpers.ts @@ -1,7 +1,7 @@ import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, TEMPLATES_NODES_FILTER } from '@/constants'; import { INodeUi, ITemplatesNode } from '@/Interface'; import dateformat from 'dateformat'; -import { INodeTypeDescription } from 'n8n-workflow'; +import {IDataObject, INodeTypeDescription} from 'n8n-workflow'; const KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2']; const SI_SYMBOL = ['', 'k', 'M', 'G', 'T', 'P', 'E']; @@ -68,3 +68,9 @@ export function isString(value: unknown): value is string { export function isNumber(value: unknown): value is number { return typeof value === 'number'; } + +export function stringSizeInBytes(input: string | IDataObject | IDataObject[] | undefined): number { + if (input === undefined) return 0; + + return new Blob([typeof input === 'string' ? input : JSON.stringify(input)]).size; +} diff --git a/packages/editor-ui/src/components/mixins/nodeHelpers.ts b/packages/editor-ui/src/components/mixins/nodeHelpers.ts index 75c57cfc5f365..6fae0e4a9bf4c 100644 --- a/packages/editor-ui/src/components/mixins/nodeHelpers.ts +++ b/packages/editor-ui/src/components/mixins/nodeHelpers.ts @@ -47,10 +47,14 @@ export const nodeHelpers = mixins( return Object.keys(node.parameters).includes('nodeCredentialType'); }, + isObjectLiteral(maybeObject: unknown): maybeObject is { [key: string]: string } { + return typeof maybeObject === 'object' && maybeObject !== null && !Array.isArray(maybeObject); + }, + isCustomApiCallSelected (nodeValues: INodeParameters): boolean { const { parameters } = nodeValues; - if (!isObjectLiteral(parameters)) return false; + if (!this.isObjectLiteral(parameters)) return false; return ( parameters.resource !== undefined && parameters.resource.includes(CUSTOM_API_CALL_KEY) || @@ -73,11 +77,13 @@ export const nodeHelpers = mixins( // Returns all the issues of the node getNodeIssues (nodeType: INodeTypeDescription | null, node: INodeUi, ignoreIssues?: string[]): INodeIssues | null { + const pinDataNodeNames = Object.keys(this.$store.getters.pinData || {}); + let nodeIssues: INodeIssues | null = null; ignoreIssues = ignoreIssues || []; - if (node.disabled === true) { - // Ignore issues on disabled nodes + if (node.disabled === true || pinDataNodeNames.includes(node.name)) { + // Ignore issues on disabled and pindata nodes return null; } @@ -510,7 +516,3 @@ declare namespace HttpRequestNode { }; } } - -function isObjectLiteral(maybeObject: unknown): maybeObject is { [key: string]: string } { - return typeof maybeObject === 'object' && maybeObject !== null && !Array.isArray(maybeObject); -} diff --git a/packages/editor-ui/src/components/mixins/pinData.ts b/packages/editor-ui/src/components/mixins/pinData.ts new file mode 100644 index 0000000000000..d558bd7d84306 --- /dev/null +++ b/packages/editor-ui/src/components/mixins/pinData.ts @@ -0,0 +1,85 @@ +import Vue from 'vue'; +import { INodeUi } from "@/Interface"; +import {IDataObject, PinData} from "n8n-workflow"; +import {stringSizeInBytes} from "@/components/helpers"; +import {MAX_WORKFLOW_PINNED_DATA_SIZE, PIN_DATA_NODE_TYPES_DENYLIST} from "@/constants"; + +interface PinDataContext { + node: INodeUi; + $showError(error: Error, title: string): void; +} + +export const pinData = (Vue as Vue.VueConstructor).extend({ + computed: { + pinData (): PinData[string] | undefined { + return this.node ? this.$store.getters['pinDataByNodeName'](this.node!.name) : undefined; + }, + hasPinData (): boolean { + return !!this.node && typeof this.pinData !== 'undefined'; + }, + isPinDataNodeType(): boolean { + return !!this.node && !PIN_DATA_NODE_TYPES_DENYLIST.includes(this.node.type); + }, + }, + methods: { + isValidPinDataJSON(data: string): boolean { + try { + JSON.parse(data); + + return true; + } catch (error) { + const title = this.$locale.baseText('runData.editOutputInvalid'); + + const toRemove = new RegExp(/JSON\.parse:|of the JSON data/, 'g'); + const message = error.message.replace(toRemove, '').trim(); + const positionMatchRegEx = /at position (\d+)/; + const positionMatch = error.message.match(positionMatchRegEx); + + error.message = message.charAt(0).toUpperCase() + message.slice(1); + error.message = error.message.replace( + 'Unexpected token \' in JSON', + this.$locale.baseText('runData.editOutputInvalid.singleQuote'), + ); + + if (positionMatch) { + const position = parseInt(positionMatch[1], 10); + const lineBreaksUpToPosition = (data.slice(0, position).match(/\n/g) || []).length; + + error.message = error.message.replace(positionMatchRegEx, + this.$locale.baseText('runData.editOutputInvalid.atPosition', { + interpolate: { + position: `${position}`, + }, + }), + ); + + error.message = `${ + this.$locale.baseText('runData.editOutputInvalid.onLine', { + interpolate: { + line: `${lineBreaksUpToPosition + 1}`, + }, + }) + } ${error.message}`; + } + + this.$showError(error, title); + + return false; + } + }, + isValidPinDataSize(data: string | object): boolean { + if (typeof data === 'object') data = JSON.stringify(data); + + if (this.$store.getters['pinDataSize'] + stringSizeInBytes(data) > MAX_WORKFLOW_PINNED_DATA_SIZE) { + this.$showError( + new Error(this.$locale.baseText('ndv.pinData.error.tooLarge.description')), + this.$locale.baseText('ndv.pinData.error.tooLarge.title'), + ); + + return false; + } + + return true; + }, + }, +}); diff --git a/packages/editor-ui/src/components/mixins/workflowHelpers.ts b/packages/editor-ui/src/components/mixins/workflowHelpers.ts index 6fc7e5f83e425..cb21cdd8f1105 100644 --- a/packages/editor-ui/src/components/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/components/mixins/workflowHelpers.ts @@ -328,6 +328,7 @@ export const workflowHelpers = mixins( const data: IWorkflowData = { name: this.$store.getters.workflowName, nodes, + pinData: this.$store.getters.pinData, connections: workflowConnections, active: this.$store.getters.isActive, settings: this.$store.getters.workflowSettings, diff --git a/packages/editor-ui/src/components/mixins/workflowRun.ts b/packages/editor-ui/src/components/mixins/workflowRun.ts index ee243467ce1d9..135823446d2be 100644 --- a/packages/editor-ui/src/components/mixins/workflowRun.ts +++ b/packages/editor-ui/src/components/mixins/workflowRun.ts @@ -188,6 +188,7 @@ export const workflowRun = mixins( const startRunData: IStartRunData = { workflowData, runData: newRunData, + pinData: workflowData.pinData, startNodes, }; if (nodeName) { @@ -208,6 +209,7 @@ export const workflowRun = mixins( data: { resultData: { runData: newRunData || {}, + pinData: workflowData.pinData, startNodes, workflowData, }, diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 56bab6a9d7396..6986ba81a5f68 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -1,3 +1,5 @@ +export const MAX_WORKFLOW_SIZE = 16777216; // Workflow size limit in bytes +export const MAX_WORKFLOW_PINNED_DATA_SIZE = 12582912; // Workflow pinned data size limit in bytes export const MAX_DISPLAY_DATA_SIZE = 204800; export const MAX_DISPLAY_ITEMS_AUTO_ALL = 250; export const NODE_NAME_PREFIX = 'node-'; @@ -47,6 +49,7 @@ export const BREAKPOINT_XL = 1920; export const N8N_IO_BASE_URL = `https://api.n8n.io/api/`; +export const DATA_PINNING_DOCS_URL = 'https://docs.n8n.io/data/data-pinning/'; // node types export const BAMBOO_HR_NODE_TYPE = 'n8n-nodes-base.bambooHr'; @@ -78,6 +81,7 @@ export const SET_NODE_TYPE = 'n8n-nodes-base.set'; export const SERVICENOW_NODE_TYPE = 'n8n-nodes-base.serviceNow'; export const SLACK_NODE_TYPE = 'n8n-nodes-base.slack'; export const SPREADSHEET_FILE_NODE_TYPE = 'n8n-nodes-base.spreadsheetFile'; +export const SPLIT_IN_BATCHES_NODE_TYPE = 'n8n-nodes-base.splitInBatches'; export const START_NODE_TYPE = 'n8n-nodes-base.start'; export const SWITCH_NODE_TYPE = 'n8n-nodes-base.switch'; export const THE_HIVE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.theHiveTrigger'; @@ -89,6 +93,16 @@ export const XERO_NODE_TYPE = 'n8n-nodes-base.xero'; export const ZENDESK_NODE_TYPE = 'n8n-nodes-base.zendesk'; export const ZENDESK_TRIGGER_NODE_TYPE = 'n8n-nodes-base.zendeskTrigger'; +export const MULTIPLE_OUTPUT_NODE_TYPES = [ + IF_NODE_TYPE, + SWITCH_NODE_TYPE, +]; + +export const PIN_DATA_NODE_TYPES_DENYLIST = [ + ...MULTIPLE_OUTPUT_NODE_TYPES, + SPLIT_IN_BATCHES_NODE_TYPE, +]; + // Node creator export const CORE_NODES_CATEGORY = 'Core Nodes'; export const CUSTOM_NODES_CATEGORY = 'Custom Nodes'; @@ -193,6 +207,8 @@ export const MODAL_CONFIRMED = 'confirmed'; export const VALID_EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; export const LOCAL_STORAGE_ACTIVATION_FLAG = 'N8N_HIDE_ACTIVATION_ALERT'; +export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG = 'N8N_PIN_DATA_DISCOVERY_NDV'; +export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG = 'N8N_PIN_DATA_DISCOVERY_CANVAS'; export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename='; export const HIRING_BANNER = ` @@ -242,3 +258,14 @@ export enum VIEWS { API_SETTINGS = "APISettings", NOT_FOUND = "NotFoundView", } + +export const TEST_PIN_DATA = [ + { + name: "First item", + code: 1, + }, + { + name: "Second item", + code: 2, + }, +]; diff --git a/packages/editor-ui/src/event-bus/data-pinning-event-bus.ts b/packages/editor-ui/src/event-bus/data-pinning-event-bus.ts new file mode 100644 index 0000000000000..7eccde2710e4e --- /dev/null +++ b/packages/editor-ui/src/event-bus/data-pinning-event-bus.ts @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export const dataPinningEventBus = new Vue(); diff --git a/packages/editor-ui/src/modules/ui.ts b/packages/editor-ui/src/modules/ui.ts index 60a0891c0a4c5..1994083feb745 100644 --- a/packages/editor-ui/src/modules/ui.ts +++ b/packages/editor-ui/src/modules/ui.ts @@ -96,6 +96,10 @@ const module: Module = { }, output: { displayMode: 'table', + editMode: { + enabled: false, + value: '', + }, }, }, mainPanelPosition: 0.5, @@ -126,6 +130,7 @@ const module: Module = { }, inputPanelDispalyMode: (state: IUiState) => state.ndv.input.displayMode, outputPanelDispalyMode: (state: IUiState) => state.ndv.output.displayMode, + outputPanelEditMode: (state: IUiState): IUiState['ndv']['output']['editMode'] => state.ndv.output.editMode, mainPanelPosition: (state: IUiState) => state.mainPanelPosition, }, mutations: { @@ -170,6 +175,12 @@ const module: Module = { setPanelDisplayMode: (state: IUiState, params: {pane: 'input' | 'output', mode: IRunDataDisplayMode}) => { Vue.set(state.ndv[params.pane], 'displayMode', params.mode); }, + setOutputPanelEditModeEnabled: (state: IUiState, payload: boolean) => { + Vue.set(state.ndv.output.editMode, 'enabled', payload); + }, + setOutputPanelEditModeValue: (state: IUiState, payload: string) => { + Vue.set(state.ndv.output.editMode, 'value', payload); + }, setMainPanelRelativePosition(state: IUiState, relativePosition: number) { state.mainPanelPosition = relativePosition; }, diff --git a/packages/editor-ui/src/plugins/components.ts b/packages/editor-ui/src/plugins/components.ts index 1618c58392159..3b688763b3a64 100644 --- a/packages/editor-ui/src/plugins/components.ts +++ b/packages/editor-ui/src/plugins/components.ts @@ -50,6 +50,7 @@ import { N8nActionToggle, N8nButton, N8nElButton, + N8nCallout, N8nCard, N8nIcon, N8nIconButton, @@ -90,6 +91,7 @@ Vue.use(N8nActionToggle); Vue.use(N8nAvatar); Vue.component('n8n-button', N8nButton); Vue.component('el-button', N8nElButton); +Vue.component('n8n-callout', N8nCallout); Vue.component('n8n-card', N8nCard); Vue.component('n8n-form-box', N8nFormBox); Vue.component('n8n-form-inputs', N8nFormInputs); diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 5aaaec4135b31..3b91115711473 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -9,6 +9,7 @@ "generic.cancel": "Cancel", "generic.delete": "Delete", "generic.copy": "Copy", + "generic.or": "or", "generic.clickToCopy": "Click to copy", "generic.copiedToClipboard": "Copied to clipboard", "generic.beta": "beta", @@ -332,7 +333,10 @@ "ndv.input.notConnected.title": "Wire me up", "ndv.input.notConnected.message": "This node can only receive input data if you connect it to another node.", "ndv.input.notConnected.learnMore": "Learn more", + "ndv.input.disabled": "The '{nodeName}' node is disabled and won’t execute.", + "ndv.input.disabled.cta": "Enable it", "ndv.output": "Output", + "ndv.output.edit": "Edit Output", "ndv.output.all": "all", "ndv.output.branch": "Branch", "ndv.output.executing": "Executing node...", @@ -346,7 +350,9 @@ "ndv.output.pageSize": "Page Size", "ndv.output.run": "Run", "ndv.output.runNodeHint": "Execute this node to output data", - "ndv.output.staleDataWarning": "Node parameters have changed.
Execute node again to refresh output.", + "ndv.output.insertTestData": "insert test data", + "ndv.output.staleDataWarning.regular": "Node parameters have changed.
Execute node again to refresh output.", + "ndv.output.staleDataWarning.pinData": "Node parameter changes will not affect pinned output data.", "ndv.output.tooMuchData.message": "The node contains {size} MB of data. Displaying it may cause problems.
If you do decide to display it, avoid the JSON view.", "ndv.output.tooMuchData.showDataAnyway": "Show data anyway", "ndv.output.tooMuchData.title": "Output data is huge!", @@ -354,6 +360,20 @@ "ndv.title.cancel": "Cancel", "ndv.title.rename": "Rename", "ndv.title.renameNode": "Rename node", + "ndv.pinData.pin.title": "Pin data", + "ndv.pinData.pin.description": "Node will always output this data instead of executing. You can also pin data from previous executions.", + "ndv.pinData.pin.link": "More info", + "ndv.pinData.pin.multipleRuns.title": "Run #{index} was pinned", + "ndv.pinData.pin.multipleRuns.description": "This run will be outputted each time the node is run.", + "ndv.pinData.unpinAndExecute.title": "Unpin output data?", + "ndv.pinData.unpinAndExecute.description": "Executing a node overwrites pinned data.", + "ndv.pinData.unpinAndExecute.cancel": "Cancel", + "ndv.pinData.unpinAndExecute.confirm": "Unpin and execute", + "ndv.pinData.beforeClosing.title": "Save output changes before closing?", + "ndv.pinData.beforeClosing.cancel": "Discard", + "ndv.pinData.beforeClosing.confirm": "Save", + "ndv.pinData.error.tooLarge.title": "Output data is too large to pin", + "ndv.pinData.error.tooLarge.description": "You can pin at most 12MB of output per workflow.", "noTagsView.readyToOrganizeYourWorkflows": "Ready to organize your workflows?", "noTagsView.withWorkflowTagsYouReFree": "With workflow tags, you're free to create the perfect tagging system for your flows", "node.activateDeactivateNode": "Activate/Deactivate Node", @@ -367,6 +387,8 @@ "node.nodeIsWaitingTill": "Node is waiting until {date} {time}", "node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall": "The node is waiting for an incoming webhook call (indefinitely)", "node.waitingForYouToCreateAnEventIn": "Waiting for you to create an event in {nodeType}", + "node.discovery.pinData.canvas": "You can pin this output instead of waiting for a test event. Open node to do so.", + "node.discovery.pinData.ndv": "You can pin this output instead of waiting for a test event.", "nodeBase.clickToAddNodeOrDragToConnect": "Click to add node
or drag to connect", "nodeCreator.categoryNames.analytics": "Analytics", "nodeCreator.categoryNames.communication": "Communication", @@ -629,14 +651,25 @@ "runData.unlinking.hint": "Unlink displayed input and output runs", "runData.binary": "Binary", "runData.copyItemPath": "Copy Item Path", + "runData.copyItemPath.toast": "Item path copied", "runData.copyParameterPath": "Copy Parameter Path", + "runData.copyParameterPath.toast": "Parameter path copied", + "runData.copyValue": "Copy Selection", + "runData.copyValue.toast": "Output data copied", "runData.copyToClipboard": "Copy to Clipboard", - "runData.copyValue": "Copy Value", + "runData.copyDisabled": "First click on the output data you want to copy, then click this button.", + "runData.editOutput": "Edit Output", + "runData.editOutputInvalid": "Problem with output data", + "runData.editOutputInvalid.singleQuote": "Unexpected single quote. Please use double quotes (\") instead", + "runData.editOutputInvalid.onLine": "On line {line}:", + "runData.editOutputInvalid.atPosition": "(at position {position})", + "runData.editValue": "Edit Value", "runData.downloadBinaryData": "Download", "runData.executeNode": "Execute Node", "runData.executionTime": "Execution Time", "runData.fileExtension": "File Extension", "runData.fileName": "File Name", + "runData.invalidPinnedData": "Invalid pinned data", "runData.items": "Items", "runData.json": "JSON", "runData.mimeType": "Mime Type", @@ -649,6 +682,12 @@ "runData.showBinaryData": "View", "runData.startTime": "Start Time", "runData.table": "Table", + "runData.pindata.learnMore": "Learn more", + "runData.pindata.thisDataIsPinned": "This data is pinned.", + "runData.pindata.unpin": "Unpin", + "runData.editor.save": "Save", + "runData.editor.cancel": "Cancel", + "runData.editor.copyDataInfo": "You can copy data from previous executions and paste it above.", "saveButton.save": "@:_reusableBaseText.save", "saveButton.saved": "Saved", "saveButton.saving": "Saving", diff --git a/packages/editor-ui/src/plugins/icons.ts b/packages/editor-ui/src/plugins/icons.ts index c7d46714f1d79..61ea7d1830366 100644 --- a/packages/editor-ui/src/plugins/icons.ts +++ b/packages/editor-ui/src/plugins/icons.ts @@ -90,6 +90,7 @@ import { faTasks, faTerminal, faThLarge, + faThumbtack, faTimes, faTrash, faUndo, @@ -199,6 +200,7 @@ addIcon(faTable); addIcon(faTasks); addIcon(faTerminal); addIcon(faThLarge); +addIcon(faThumbtack); addIcon(faTimes); addIcon(faTrash); addIcon(faUndo); diff --git a/packages/editor-ui/src/store.ts b/packages/editor-ui/src/store.ts index 26ac7bf79cfdb..4c1563f23e743 100644 --- a/packages/editor-ui/src/store.ts +++ b/packages/editor-ui/src/store.ts @@ -2,7 +2,10 @@ import Vue from 'vue'; import Vuex from 'vuex'; -import { PLACEHOLDER_EMPTY_WORKFLOW_ID, DEFAULT_NODETYPE_VERSION } from '@/constants'; +import { + PLACEHOLDER_EMPTY_WORKFLOW_ID, + DEFAULT_NODETYPE_VERSION, +} from '@/constants'; import { IConnection, @@ -14,6 +17,7 @@ import { IRunData, ITaskData, IWorkflowSettings, + PinData, } from 'n8n-workflow'; import { @@ -39,6 +43,7 @@ import users from './modules/users'; import workflows from './modules/workflows'; import versions from './modules/versions'; import templates from './modules/templates'; +import {stringSizeInBytes} from "@/components/helpers"; Vue.use(Vuex); @@ -87,6 +92,7 @@ const state: IRootState = { nodes: [], settings: {}, tags: [], + pinData: {}, }, sidebarMenuItems: [], instanceId: '', @@ -202,6 +208,23 @@ export const store = new Vuex.Store({ Vue.set(state, 'selectedNodes', []); }, + // Pin data + pinData(state, payload: { node: INodeUi, data: IDataObject }) { + if (state.workflow.pinData) { + Vue.set(state.workflow.pinData, payload.node.name, payload.data); + } + + state.stateIsDirty = true; + }, + unpinData(state, payload: { node: INodeUi }) { + if (state.workflow.pinData) { + Vue.set(state.workflow.pinData, payload.node.name, undefined); + delete state.workflow.pinData[payload.node.name]; + } + + state.stateIsDirty = true; + }, + // Active setActive (state, newActive: boolean) { state.workflow.active = newActive; @@ -332,6 +355,11 @@ export const store = new Vuex.Store({ Vue.set(state.nodeMetadata, nameData.new, state.nodeMetadata[nameData.old]); Vue.delete(state.nodeMetadata, nameData.old); + + if (state.workflow.pinData && state.workflow.pinData.hasOwnProperty(nameData.old)) { + Vue.set(state.workflow.pinData, nameData.new, state.workflow.pinData[nameData.old]); + Vue.delete(state.workflow.pinData, nameData.old); + } }, resetAllNodesIssues (state) { @@ -424,6 +452,10 @@ export const store = new Vuex.Store({ removeNode (state, node: INodeUi) { Vue.delete(state.nodeMetadata, node.name); + if (state.workflow.pinData && state.workflow.pinData.hasOwnProperty(node.name)) { + Vue.delete(state.workflow.pinData, node.name); + } + for (let i = 0; i < state.workflow.nodes.length; i++) { if (state.workflow.nodes[i].name === node.name) { state.workflow.nodes.splice(i, 1); @@ -436,6 +468,11 @@ export const store = new Vuex.Store({ if (data.setStateDirty === true) { state.stateIsDirty = true; } + + if (data.removePinData) { + state.workflow.pinData = {}; + } + state.workflow.nodes.splice(0, state.workflow.nodes.length); state.nodeMetadata = {}; }, @@ -607,6 +644,10 @@ export const store = new Vuex.Store({ Vue.set(state.workflow, 'settings', workflowSettings); }, + setWorkflowPinData (state, pinData: Record) { + Vue.set(state.workflow, 'pinData', pinData); + }, + setWorkflowTagIds (state, tags: string[]) { Vue.set(state.workflow, 'tags', tags); }, @@ -844,6 +885,27 @@ export const store = new Vuex.Store({ return state.nodeTypes; }, + /** + * Pin data + */ + + pinData: (state): PinData | undefined => { + return state.workflow.pinData; + }, + pinDataByNodeName: (state) => (nodeName: string) => { + return state.workflow.pinData && state.workflow.pinData[nodeName]; + }, + pinDataSize: (state) => { + return state.workflow.nodes + .reduce((acc, node) => { + if (typeof node.pinData !== 'undefined' && node.name !== state.activeNode) { + acc += stringSizeInBytes(node.pinData); + } + + return acc; + }, 0); + }, + /** * Getter for node default names ending with a number: `'S3'`, `'Magento 2'`, etc. */ diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 4f3c20a74bdfe..50fad1352da81 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -548,6 +548,7 @@ export default mixins( this.$store.commit('setWorkflowId', PLACEHOLDER_EMPTY_WORKFLOW_ID); this.$store.commit('setWorkflowExecutionData', data); + this.$store.commit('setWorkflowPinData', data.workflowData.pinData); await this.addNodes(JSON.parse(JSON.stringify(data.workflowData.nodes)), JSON.parse(JSON.stringify(data.workflowData.connections))); this.$nextTick(() => { @@ -555,6 +556,7 @@ export default mixins( this.$store.commit('setStateDirty', false); }); + this.$externalHooks().run('execution.open', { workflowId: data.workflowData.id, workflowName: data.workflowData.name, executionId }); this.$telemetry.track('User opened read-only execution', { workflow_id: data.workflowData.id, execution_mode: data.mode, execution_finished: data.finished }); @@ -609,6 +611,11 @@ export default mixins( this.resetWorkspace(); data.workflow.nodes = CanvasHelpers.getFixedNodesList(data.workflow.nodes); await this.addNodes(data.workflow.nodes, data.workflow.connections); + + if (data.workflow.pinData) { + this.$store.commit('setWorkflowPinData', data.workflow.pinData); + } + this.$nextTick(() => { this.zoomToFit(); }); @@ -677,6 +684,7 @@ export default mixins( this.$store.commit('setWorkflowId', workflowId); this.$store.commit('setWorkflowName', {newName: data.name, setStateDirty: false}); this.$store.commit('setWorkflowSettings', data.settings || {}); + this.$store.commit('setWorkflowPinData', data.pinData || {}); const tags = (data.tags || []) as ITag[]; this.$store.commit('tags/upsertTags', tags); @@ -1311,6 +1319,10 @@ export default mixins( }); }); + if (workflowData.pinData) { + this.$store.commit('setWorkflowPinData', workflowData.pinData); + } + const tagsEnabled = this.$store.getters['settings/areTagsEnabled']; if (importTags && tagsEnabled && Array.isArray(workflowData.tags)) { const allTags: ITag[] = await this.$store.dispatch('tags/fetchAll'); @@ -2167,6 +2179,14 @@ export default mixins( await this.addNodes([newNodeData]); + const pinData = this.$store.getters['pinDataByNodeName'](nodeName); + if (pinData) { + this.$store.commit('pinData', { + node: newNodeData, + data: pinData, + }); + } + this.$store.commit('setStateDirty', true); // Automatically deselect all nodes and select the current one and also active @@ -2831,7 +2851,7 @@ export default mixins( } this.$store.commit('removeAllConnections', {setStateDirty: false}); - this.$store.commit('removeAllNodes', {setStateDirty: false}); + this.$store.commit('removeAllNodes', { setStateDirty: false, removePinData: true }); // Reset workflow execution data this.$store.commit('setWorkflowExecutionData', null); diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 4a87b614ba914..8908a082c31c4 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -798,6 +798,11 @@ export interface INode { parameters: INodeParameters; credentials?: INodeCredentials; webhookId?: string; + pinData?: IDataObject; +} + +export interface PinData { + [nodeName: string]: IDataObject[]; } export interface INodes { @@ -1299,6 +1304,7 @@ export interface IRunExecutionData { resultData: { error?: ExecutionError; runData: IRunData; + pinData?: PinData; lastNodeExecuted?: string; }; executionData?: { @@ -1372,6 +1378,7 @@ export interface IWorkflowBase { connections: IConnections; settings?: IWorkflowSettings; staticData?: IDataObject; + pinData?: PinData; } export interface IWorkflowCredentials { @@ -1492,6 +1499,7 @@ export interface INodesGraph { node_connections: IDataObject[]; nodes: INodesGraphNode; notes: INotesGraphNode; + is_pinned: boolean; } export interface INodesGraphNode { diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 723709e9f6cdd..366f760d7fe67 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -1060,12 +1060,13 @@ export function getNodeWebhookUrl( export function getNodeParametersIssues( nodePropertiesArray: INodeProperties[], node: INode, + pinDataNodeNames?: string[], ): INodeIssues | null { const foundIssues: INodeIssues = {}; let propertyIssues: INodeIssues; - if (node.disabled === true) { - // Ignore issues on disabled nodes + if (node.disabled === true || pinDataNodeNames?.includes(node.name)) { + // Ignore issues on disabled and pindata nodes return null; } diff --git a/packages/workflow/src/TelemetryHelpers.ts b/packages/workflow/src/TelemetryHelpers.ts index 72f9054cfc944..3c29e5fa15342 100644 --- a/packages/workflow/src/TelemetryHelpers.ts +++ b/packages/workflow/src/TelemetryHelpers.ts @@ -120,6 +120,7 @@ export function generateNodesGraph( node_connections: [], nodes: {}, notes: {}, + is_pinned: Object.keys(workflow.pinData ?? {}).length > 0, }; const nodeNameAndIndex: INodeNameIndex = {}; const webhookNodeNames: string[] = []; diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 1492ee8d85985..2205c23b2f82e 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -252,6 +252,7 @@ export class Workflow { checkReadyForExecution(inputData: { startNode?: string; destinationNode?: string; + pinDataNodeNames?: string[]; }): IWorfklowIssues | null { let node: INode; let nodeType: INodeType | undefined; @@ -287,7 +288,11 @@ export class Workflow { typeUnknown: true, }; } else { - nodeIssues = NodeHelpers.getNodeParametersIssues(nodeType.description.properties, node); + nodeIssues = NodeHelpers.getNodeParametersIssues( + nodeType.description.properties, + node, + inputData.pinDataNodeNames, + ); } if (nodeIssues !== null) {