From 15693b0056097129a57dfc600807dbc5e1cc07f1 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Wed, 20 Jul 2022 18:50:39 +0300 Subject: [PATCH] feat(editor): Add data pinning functionality (#3511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Design system color improvements and button component redesign. * feat: Added button focus state and unit tests. * refactor: Aligned n8n-button usage inside of editor-ui. * test: Updated snapshots. * refactor: Extracted focus outline width into scss variable. * fix: Fixed select input border-radius. * refactor: Removed element-ui references in button. * fix: Fixed scss variable imports. * feat: Added color-neutral variable story. * fix: Fixed color-secondary variable definition. * feat: Added color-white story. * test: Updated button snapshot. * feat: Replaced zoom buttons with new n8n-icon-button. * feat: Added stories for float utilities. * chore: Updated color shades generation code for later use. * chore: Removed color-white code. * chore: Updated story properties for button components. * fix: Added el-button fallback for places where el-button is not replaceable (messagebox). * feat: Reverted to css modules. Replaced el-button with n8n-button at application level. * test: Updated button snapshot. * fix: Fixed element-ui locally referenced buttons (via components: {}). * fix: Updated colors. Removed irrelevant validation. Added ElButton override component. * test: Updated button override snapshot. * fix: Various button adjustments and fixes. * fix: Updated button disabled state. * test: Updated snapshots. * fix: Consolidated css variables changes. * Data pinning (#3512) * refactor: Aligned n8n-button usage inside of editor-ui. * feat: Added edit data button on json hover. * feat: Extracted code editor into separate form component. * feat: Added edit data button on json hover. * feat: Added pinData and edit mode methods. * :fire: Remove conflict markers * :pencil2: Update i18n keys * :zap: Add JSON validation * :card_file_box: Add `pinData` column to `workflow_entity` * :blue_book: Tighten type * :zap: Make `pinData` column nullable * :zap: Adjust workflow endpoints for pin data * :blue_book: Improve types * :pencil2: Improve wording * Inject pindata into items flow (#3420) * :zap: Inject pin data - Second approach * :fire: Remove unneeded lint exception * feat: Added edit data button on json hover. * feat: Extracted code editor into separate form component. * feat: Added edit data button on json hover. * fix: Fixed rebase conflicts. * :rewind: Undo button change * :bug: Fix runNode call Adjust per update in bdb84130d687811d65337ff6b025e7cb0eae8256 * :test_tube: Fix workflow tests * :bug: More merge conflict fixes * feat: Added pin/unpin button and store mutations. * feat: Size check. Various design and ux improvements. * :zap: Add transformer * :zap: Hoist pin data * :zap: Adjust endpoints for hoisted pin data * :blue_book: Expand interface * :bug: Fix stray array * :shirt: Fix build * :shirt: Add lint exception * :shirt: Fix header * :art: Add color secondary tints * :sparkles: Create `HeaderMessage` component * :zap: Adjust `InfoTip` component * :sparkles: Add `HeaderMessage` to `RunData` * :bug: Fix console error * :shirt: Fix lint * :zap: Consolidate `HeaderMessage` and `Callout` * :rewind: Undo `InfoTip` changes * :fire: Remove duplicate icons * :zap: Simplify template * :art: Change cursor for action text * :shirt: Fix lint * :zap: Add URL * :bug: Fix handler name * :zap: Use constant * :recycle: Refactor per feedback * fix: Various fixes after data pinning relocation. * fix: Added store mutation for setting pinned data. * feat: Added pinned state for workflow canvas node. * fix: Fixed workflow saving. * fix: Removed pinData hoisting (no longer necessary). * feat: Added canPinData flag to hide for input pane and binary data. Fixed unpin and execute flow. * :zap: Fixes for canvas pin data (#3587) * :zap: Fixes for canvas pin data * :blue_book: Rename type * :test_tube: Fix unrelated Public API test * :fire: Remove logging * feat: Updated pinData mixin to no longer include extra fields. * :zap: Output same pindata for every run * :art: Fix cropping * :fire: Remove unrelated logging * feat: Moved edit button next to pin button. * feat: Changed data to be inserted for empty state. * chore: Changed invalid editor output translation. * feat: Added error line reporting on JSON Validation. * feat: Migrated pinData edit mode to store. * chore: Merged duplicate node border color condition. * feat: Moved pin data validation to mixin. Added check before closing ndv modal. * fix: Changed pinned data size calculation to discard active node pin data. * feat: Added support for rename and delete node with pin data. * feat: Simplified editing state. Fixed edit mode in input panel after store migration. * feat: Various data pinning improvements. * fix: Fixed callout link underline. * refactor: Added support for both string and objects for data size check. * feat: Added disabled node check for input panel. Fixed monaco editor resizing. * fix: Fixed edit mode footer size. * :zap: Fix pindata items per run * :shirt: Remove unneeded exception * refactor: Added isValidPinData() helper method. * refactor: Changed how string size in bytes in calculated.g * refactor: Updated pinData mixin interface. * refactor: Merged filter and reduce in pinDataSize calculation. * fix: Changed code-editor to correct type. * fix: Added insert test data message to trigger nodes. * feat: Disabled data pinning for multiple output nodes. * refactor: Updated ndv.input.disabled translation to include node name. * refactor: Aligned n8n-button usage inside of editor-ui. * feat: Added edit data button on json hover. * feat: Extracted code editor into separate form component. * feat: Added edit data button on json hover. * feat: Added pinData and edit mode methods. * :fire: Remove conflict markers * :pencil2: Update i18n keys * :zap: Add JSON validation * :card_file_box: Add `pinData` column to `workflow_entity` * :blue_book: Tighten type * :zap: Make `pinData` column nullable * :zap: Adjust workflow endpoints for pin data * :blue_book: Improve types * :pencil2: Improve wording * Inject pindata into items flow (#3420) * :zap: Inject pin data - Second approach * :fire: Remove unneeded lint exception * feat: Added edit data button on json hover. * feat: Extracted code editor into separate form component. * feat: Added edit data button on json hover. * fix: Fixed rebase conflicts. * :rewind: Undo button change * :bug: Fix runNode call Adjust per update in bdb84130d687811d65337ff6b025e7cb0eae8256 * :test_tube: Fix workflow tests * :bug: More merge conflict fixes * feat: Added pin/unpin button and store mutations. * feat: Size check. Various design and ux improvements. * :zap: Add transformer * :zap: Hoist pin data * :zap: Adjust endpoints for hoisted pin data * :blue_book: Expand interface * :bug: Fix stray array * :shirt: Fix build * :art: Add color secondary tints * :sparkles: Create `HeaderMessage` component * :zap: Adjust `InfoTip` component * :sparkles: Add `HeaderMessage` to `RunData` * :bug: Fix console error * :shirt: Fix lint * :zap: Consolidate `HeaderMessage` and `Callout` * :rewind: Undo `InfoTip` changes * :fire: Remove duplicate icons * :zap: Simplify template * :art: Change cursor for action text * :shirt: Fix lint * :zap: Add URL * :bug: Fix handler name * :zap: Use constant * :recycle: Refactor per feedback * fix: Various fixes after data pinning relocation. * fix: Added store mutation for setting pinned data. * feat: Added pinned state for workflow canvas node. * :zap: Fixes for canvas pin data (#3587) * :zap: Fixes for canvas pin data * :blue_book: Rename type * :test_tube: Fix unrelated Public API test * :fire: Remove logging * feat: Updated pinData mixin to no longer include extra fields. * fix: Removed pinData hoisting (no longer necessary). * chore: Merged duplicate node border color condition. * :zap: Output same pindata for every run * :art: Fix cropping * :bug: Fix excess closing template tag * fix: Removed rogue template tag after merge. * fix: Fixed code-editor resizing when moving ndv panel. * feat: Added node duplication pin data. * :zap: Implement telemetry * :recycle: Add clarifications from call * fix: Fixed run data header height. * feat: Removed border from pin data callout. * feat: Added line-break before 'or insert pin data'. * feat: Changed enterEditMode to always insert test data if there's no execution data. * feat: Removed copy output tooltip. * feat: Removed unpin tooltip. * fix: Removed thumbtack icon rotation. * fix: Removed run info from Edit Output title. * feat: Hid edit and pin buttons when editing. * feat: Updated monaco code-editor padding and borders. * feat: Progress on pinData error message format * feat: Updated copy feature to work without any selected value. * feat: Moved save and cancel buttons. Cleared notifications on save. * feat: Changed pin data beforeClosing confirm text. * feat: Closing ndv when discarding or saving pindata on close. * feat: Added split in batches node to pin data denylist. * fix: Added missing margin-bottom to webhook node. * feat: Moved thumbtack icon to the right, replacing the checkmark. * fix: Hid pagination while editing. * feat: Added pin data discovery flow. * feat: Changed pin data discovery flow to avoid tooltip glitching. * fix: Changed copy selection to copy all input data. * feat: Updated pin data validation error message for unexpected single quotes. * fix: Replaced :manual='true' prop with manual shorthand. * fix: Removed unused variable. * chore: Renamed translation key to node.discovery.pinData. * refactor: Extracted isPinDataNodeType to pinData mixin. * fix: Updated watch condition to improve performance. * refactor: Renamed some pin data variables and methods as per review. * fix: Added partial translation for JSON.parse pin data error messages. * chore: Temporarily disabled failing unit test. * :test_tube: Fix data pinning workflow retrieval test * :fire: Remove unused imports * :fire: Remove leftover line * :zap: Skip pindata node issues on BE * :zap: Skip pindata node issues on FE * :zap: Hide `RunInfo` for pindata node * :zap: Hide purple banner in edit output mode * feat: Updated data pinning discoverability flow. * fix: Fixed paginated data pinning. * fix: Disabled pin data in read only mode. * :bug: Fix runtime error with non-array * fix: Loading pin data when opening execution. * :zap: Adjust stale data warning for pinned data * :zap: Skip auth in endpoint * :zap: Mark start node for pinned trigger * :pencil2: Comment on passthrough * :fire: Remove comment * Final pindata metrics changes (#3673) * :bug: Fix `pinData` tracked as `0` * :zap: Add `is_pinned` to `nodesGraph` * :blue_book: Extend `IWorkflowBase` * :zap: Handle `pinData` being `undefined` * :zap: Add `data_pinning_tooltip_presented` * :recycle: Refactor to remove circular dependency * fix: Added pin data handling when importing workflow. (#3698) * :fire: Remove helper from WorkflowExecute * :zap: Add logic for single pinned trigger * :shirt: Remove lint exception * fix: Added pin data handling in importWorkflowExact. * N8N-4077 data pinning discoverability part 2 (#3701) * fix: Fixed pin data discovery tooltip position when moving canvas. * feat: Updated data pinning discovery tooltip copy. * Fix data pinning build (#3702) * :zap: Disable edit button for disabled node * :zap: Ensure disabled pinned nodes are passthrough * :bug: Fix JSON key unfurling in edit mode * :zap: Improve implementation * :bug: Fix console error * fix: Fixed copying pinned output data. (#3715) * Fix pinning for webhook responding with output from last node (#3719) * fix: Fixed entering edit mode after refresh. * fix: Fixed type error during build. * fix: RunData import formatting. * chore: Updated pin data types. * fix: Added missing type to stringSizeInBytes. Co-authored-by: Iván Ovejero * fix: Showing pin data without executing the node only in output pane. * fix: Updated no data message when previous node not executed. * feat: Added expression input and evaluation for pin data nodes without execution. * chore: Fixed linting issues and removed remnant console.log(). * chore: Undone package-lock changes. * fix: Removed pin data store changes. * fix: Created a new object using vuex runExecutionData. * fix: Fixed bug appearing when adding a new node after executing. * fix: Fix editor-ui build * feat: Added green node connectors when having pin data output. * chore: Fixed linting errors. * fix: Added pin data eventBus unsubscribe. * fix: Added pin data color check after adding a connection. * :art: Add pindata styles Co-authored-by: Iván Ovejero --- packages/cli/src/Interfaces.ts | 2 + packages/cli/src/Server.ts | 154 +---- packages/cli/src/WebhookHelpers.ts | 4 + packages/cli/src/WorkflowHelpers.ts | 23 +- packages/cli/src/WorkflowRunner.ts | 8 +- packages/cli/src/WorkflowRunnerProcess.ts | 16 +- packages/cli/src/api/workflows.api.ts | 134 ++++ .../src/databases/entities/WorkflowEntity.ts | 11 +- .../mysqldb/1654090101303-IntroducePinData.ts | 25 + .../src/databases/migrations/mysqldb/index.ts | 2 + .../1654090467022-IntroducePinData.ts | 33 + .../databases/migrations/postgresdb/index.ts | 2 + .../sqlite/1654089251344-IntroducePinData.ts | 33 + .../src/databases/migrations/sqlite/index.ts | 2 + .../cli/src/databases/utils/transformers.ts | 10 + packages/cli/src/requests.d.ts | 2 + .../integration/publicApi/workflows.test.ts | 5 +- .../cli/test/integration/shared/testDb.ts | 2 +- .../cli/test/integration/shared/types.d.ts | 1 + packages/cli/test/integration/shared/utils.ts | 16 +- .../test/integration/workflows.api.test.ts | 107 +++ packages/core/src/WorkflowExecute.ts | 67 +- .../components/N8nButton/Button.stories.js | 122 ---- .../components/N8nButton/Button.stories.ts | 167 +++++ .../src/components/N8nButton/Button.vue | 585 +++++++++------- .../N8nButton/__tests__/Button.spec.ts | 63 ++ .../__snapshots__/Button.spec.ts.snap | 12 + .../src/components/N8nButton/index.d.ts | 43 -- .../N8nButton/overrides/ElButton.vue | 39 ++ .../components/N8nButton/overrides/index.ts | 1 + .../N8nCallout/N8nCallout.stories.ts | 107 +++ ...utton.stories.js => IconButton.stories.ts} | 32 +- .../components/N8nIconButton/IconButton.vue | 79 ++- .../src/components/N8nLink/Link.vue | 43 +- .../src/components/N8nNotice/Notice.vue | 2 +- .../N8nNotice/__tests__/Notice.spec.ts | 2 +- .../N8nPanelCallout/PanelCallout.stories.js | 107 +++ .../N8nPanelCallout/PanelCallout.vue | 115 ++++ .../src/components/N8nPanelCallout/index.ts | 3 + .../design-system/src/components/index.ts | 4 + .../src/styleguide/colors.stories.mdx | 13 + .../styleguide/utilities/float.stories.mdx | 29 + .../design-system/theme/src/_tokens.dark.scss | 488 +++++++------ packages/design-system/theme/src/_tokens.scss | 52 +- packages/design-system/theme/src/button.scss | 129 ---- .../design-system/theme/src/calendar.scss | 1 - .../design-system/theme/src/color-picker.scss | 650 +++++++++--------- .../design-system/theme/src/common/var.scss | 398 ++++++----- .../design-system/theme/src/dropdown.scss | 3 +- packages/design-system/theme/src/index.scss | 3 +- .../design-system/theme/src/message-box.scss | 17 +- .../design-system/theme/src/transfer.scss | 3 +- .../theme/src/utilities/_float.scss | 7 + .../_spacing.scss} | 0 .../theme/src/utilities/index.scss | 2 + packages/editor-ui/src/Interface.ts | 10 + packages/editor-ui/src/components/Banner.vue | 5 +- .../editor-ui/src/components/CodeEdit.vue | 139 +--- .../src/components/CollectionParameter.vue | 6 +- .../CredentialEdit/CredentialEdit.vue | 6 +- .../components/DuplicateWorkflowDialog.vue | 2 +- .../src/components/ExecutionsList.vue | 22 +- .../components/FixedCollectionParameter.vue | 9 +- .../src/components/HoverableNodeIcon.vue | 2 +- .../editor-ui/src/components/InputPanel.vue | 2 +- .../src/components/MultipleParameter.vue | 45 +- packages/editor-ui/src/components/Node.vue | 75 +- .../src/components/NodeDetailsView.vue | 107 ++- .../src/components/NodeExecuteButton.vue | 38 +- .../editor-ui/src/components/NodeSettings.vue | 11 +- .../editor-ui/src/components/NodeTitle.vue | 2 +- .../editor-ui/src/components/OutputPanel.vue | 67 +- packages/editor-ui/src/components/RunData.vue | 600 +++++++++++++++- .../editor-ui/src/components/SaveButton.vue | 1 + .../TagsManager/TagsView/TagsTable.vue | 6 +- .../editor-ui/src/components/TemplateCard.vue | 2 +- .../editor-ui/src/components/TriggerPanel.vue | 8 +- .../src/components/VariableSelector.vue | 88 ++- .../src/components/forms/CodeEditor.vue | 136 ++++ .../editor-ui/src/components/forms/index.ts | 1 + packages/editor-ui/src/components/helpers.ts | 8 +- .../src/components/mixins/nodeHelpers.ts | 16 +- .../src/components/mixins/pinData.ts | 85 +++ .../src/components/mixins/workflowHelpers.ts | 70 +- .../src/components/mixins/workflowRun.ts | 2 + packages/editor-ui/src/constants.ts | 26 + .../src/event-bus/data-pinning-event-bus.ts | 3 + packages/editor-ui/src/modules/ui.ts | 11 + packages/editor-ui/src/n8n-theme.scss | 2 +- packages/editor-ui/src/plugins/components.ts | 11 +- .../src/plugins/i18n/locales/en.json | 43 +- packages/editor-ui/src/plugins/icons.ts | 2 + packages/editor-ui/src/store.ts | 205 ++++-- packages/editor-ui/src/views/NodeView.vue | 131 ++-- packages/editor-ui/vue.config.js | 7 + packages/workflow/src/Interfaces.ts | 8 + packages/workflow/src/NodeHelpers.ts | 5 +- packages/workflow/src/TelemetryHelpers.ts | 1 + packages/workflow/src/Workflow.ts | 7 +- 99 files changed, 4145 insertions(+), 1868 deletions(-) create mode 100644 packages/cli/src/api/workflows.api.ts create mode 100644 packages/cli/src/databases/migrations/mysqldb/1654090101303-IntroducePinData.ts create mode 100644 packages/cli/src/databases/migrations/postgresdb/1654090467022-IntroducePinData.ts create mode 100644 packages/cli/src/databases/migrations/sqlite/1654089251344-IntroducePinData.ts create mode 100644 packages/cli/test/integration/workflows.api.test.ts delete mode 100644 packages/design-system/src/components/N8nButton/Button.stories.js create mode 100644 packages/design-system/src/components/N8nButton/Button.stories.ts create mode 100644 packages/design-system/src/components/N8nButton/__tests__/Button.spec.ts create mode 100644 packages/design-system/src/components/N8nButton/__tests__/__snapshots__/Button.spec.ts.snap delete mode 100644 packages/design-system/src/components/N8nButton/index.d.ts create mode 100644 packages/design-system/src/components/N8nButton/overrides/ElButton.vue create mode 100644 packages/design-system/src/components/N8nButton/overrides/index.ts create mode 100644 packages/design-system/src/components/N8nCallout/N8nCallout.stories.ts rename packages/design-system/src/components/N8nIconButton/{IconButton.stories.js => IconButton.stories.ts} (77%) create mode 100644 packages/design-system/src/components/N8nPanelCallout/PanelCallout.stories.js create mode 100644 packages/design-system/src/components/N8nPanelCallout/PanelCallout.vue create mode 100644 packages/design-system/src/components/N8nPanelCallout/index.ts create mode 100644 packages/design-system/src/styleguide/utilities/float.stories.mdx delete mode 100644 packages/design-system/theme/src/button.scss create mode 100644 packages/design-system/theme/src/utilities/_float.scss rename packages/design-system/theme/src/{utilities.scss => utilities/_spacing.scss} (100%) create mode 100644 packages/design-system/theme/src/utilities/index.scss create mode 100644 packages/editor-ui/src/components/forms/CodeEditor.vue create mode 100644 packages/editor-ui/src/components/forms/index.ts create mode 100644 packages/editor-ui/src/components/mixins/pinData.ts create mode 100644 packages/editor-ui/src/event-bus/data-pinning-event-bus.ts diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 2edd852bcb..fea69be905 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'; @@ -688,6 +689,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 4ce71acabf..d74a83b21f 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, @@ -130,6 +131,7 @@ import { WorkflowRunner, getCredentialForUser, getCredentialWithoutUser, + IWorkflowDb, } from '.'; import config from '../config'; @@ -157,9 +159,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 { nodesController } from './api/nodes.api'; import { oauth2CredentialController } from './api/oauth2Credential.api'; import { @@ -168,6 +170,7 @@ import { isUserManagementEnabled, } from './UserManagement/UserManagementHelper'; import { loadPublicApiVersions } from './PublicApi'; +import { SharedWorkflow } from './databases/entities/SharedWorkflow'; require('body-parser-xml')(bodyParser); @@ -769,74 +772,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`, @@ -962,50 +897,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`, @@ -1204,6 +1095,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'; @@ -1211,12 +1103,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(); @@ -1254,11 +1149,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); @@ -1269,6 +1170,8 @@ class App { ), ); + this.app.use(`/${this.restEndpoint}/workflows`, workflowsController); + // Retrieves all tags, with or without usage count this.app.get( `/${this.restEndpoint}/tags`, @@ -2927,3 +2830,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 2083acb510..e737e20501 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 e625c6c591..e7af8c1b03 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 cc3b0d7fca..9cce04ffa4 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 d77f5d329f..468ef57383 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 0000000000..3afd63f8b7 --- /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 069f637737..31b2b5fe70 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 0000000000..c69a0848be --- /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 a52df459ca..bb241524f7 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -16,6 +16,7 @@ import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEm import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings'; import { CommunityNodes1652254514003 } from './1652254514003-CommunityNodes'; import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn'; +import { IntroducePinData1654090101303 } from './1654090101303-IntroducePinData'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -36,4 +37,5 @@ export const mysqlMigrations = [ AddUserSettings1652367743993, CommunityNodes1652254514003, 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 0000000000..6b28194407 --- /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 74162b2fee..ebd0a9df3f 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -14,6 +14,7 @@ import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEm import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings'; import { CommunityNodes1652254514002 } from './1652254514002-CommunityNodes'; import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn'; +import { IntroducePinData1654090467022 } from './1654090467022-IntroducePinData'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -32,4 +33,5 @@ export const postgresMigrations = [ AddUserSettings1652367743993, CommunityNodes1652254514002, 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 0000000000..d36bca491b --- /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 d0dffdf6dc..83b938b96b 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -13,6 +13,7 @@ import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEm import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings'; import { CommunityNodes1652254514001 } from './1652254514001-CommunityNodes' import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn'; +import { IntroducePinData1654089251344 } from './1654089251344-IntroducePinData'; const sqliteMigrations = [ InitialMigration1588102412422, @@ -30,6 +31,7 @@ const sqliteMigrations = [ AddUserSettings1652367743993, CommunityNodes1652254514001, AddAPIKeyColumn1652905585850, + IntroducePinData1654089251344, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/utils/transformers.ts b/packages/cli/src/databases/utils/transformers.ts index e3a77fc2df..94f706a91d 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 28f3b911a7..d355edeab5 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 e676b9c164..9d01a927f2 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 db55eb9ab5..309e0bebd6 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -201,6 +201,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};`, ); @@ -223,7 +224,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 7b71d39e1a..13fdc9ac55 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' | 'nodes'; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index 8feee9513c..ff744ab5bc 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -53,6 +53,7 @@ import type { TriggerTime, } from './types'; import type { N8nApp } from '../../../src/UserManagement/Interfaces'; +import { workflowsController } from '../../../src/api/workflows.api'; import { nodesController } from '../../../src/api/nodes.api'; import { randomName } from './random'; @@ -96,17 +97,18 @@ export async function initTestServer({ if (routerEndpoints.length) { const { apiRouters } = await loadPublicApiVersions(testServer.publicApiEndpoint); - const map: Record = { - credentials: credentialsController, - nodes: nodesController, - publicApi: apiRouters, + const map: Record = { + credentials: { controller: credentialsController, path: 'credentials' }, + workflows: { controller: workflowsController, path: 'workflows' }, + nodes: { controller: nodesController, path: 'nodes' }, + 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); } } } @@ -145,8 +147,10 @@ const classifyEndpointGroups = (endpointGroups: string[]) => { const routerEndpoints: string[] = []; const functionEndpoints: string[] = []; + const ROUTER_GROUP = ['credentials', 'nodes', 'workflows', 'publicApi']; + endpointGroups.forEach((group) => - (['credentials', 'nodes', 'publicApi'].includes(group) ? 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 0000000000..0ff64b0655 --- /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 ca2b0ecb75..f404891890 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 (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(); + if (pinData && !executionNode.disabled && pinData[executionNode.name] !== undefined) { + let nodePinData = pinData[executionNode.name]; + + if (!Array.isArray(nodePinData)) nodePinData = [nodePinData]; + + 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/N8nButton/Button.stories.js b/packages/design-system/src/components/N8nButton/Button.stories.js deleted file mode 100644 index 31a8b28d61..0000000000 --- a/packages/design-system/src/components/N8nButton/Button.stories.js +++ /dev/null @@ -1,122 +0,0 @@ -import N8nButton from './Button.vue'; -import { action } from '@storybook/addon-actions'; - -export default { - title: 'Atoms/Button', - component: N8nButton, - argTypes: { - label: { - control: 'text', - }, - title: { - control: 'text', - }, - type: { - control: 'select', - options: ['primary', 'outline', 'light', 'text', 'tertiary'], - }, - size: { - control: { - type: 'select', - options: ['mini', 'small', 'medium', 'large', 'xlarge'], - }, - }, - loading: { - control: { - type: 'boolean', - }, - }, - icon: { - control: { - type: 'text', - }, - }, - circle: { - control: { - type: 'boolean', - }, - }, - fullWidth: { - type: 'boolean', - }, - theme: { - type: 'select', - options: ['success', 'danger', 'warning'], - }, - float: { - type: 'select', - options: ['left', 'right'], - }, - }, - parameters: { - design: { - type: 'figma', - url: 'https://www.figma.com/file/DxLbnIyMK8X0uLkUguFV4n/n8n-design-system_v1?node-id=5%3A1147', - }, - }, -}; - -const methods = { - onClick: action('click'), -}; - -const Template = (args, { argTypes }) => ({ - props: Object.keys(argTypes), - components: { - N8nButton, - }, - template: '', - methods, -}); - -export const Button = Template.bind({}); -Button.args = { - label: 'Button', -}; - -const ManyTemplate = (args, { argTypes }) => ({ - props: Object.keys(argTypes), - components: { - N8nButton, - }, - template: - '
', - methods, -}); - -export const Primary = ManyTemplate.bind({}); -Primary.args = { - type: 'primary', - label: 'Button', -}; - -export const Outline = ManyTemplate.bind({}); -Outline.args = { - type: 'outline', - label: 'Button', -}; - -export const Tertiary = ManyTemplate.bind({}); -Tertiary.args = { - type: 'tertiary', - label: 'Button', -}; - -export const Light = ManyTemplate.bind({}); -Light.args = { - type: 'light', - label: 'Button', -}; - -export const WithIcon = ManyTemplate.bind({}); -WithIcon.args = { - label: 'Button', - icon: 'plus-circle', -}; - -export const Text = ManyTemplate.bind({}); -Text.args = { - type: 'text', - label: 'Button', - icon: 'plus-circle', -}; diff --git a/packages/design-system/src/components/N8nButton/Button.stories.ts b/packages/design-system/src/components/N8nButton/Button.stories.ts new file mode 100644 index 0000000000..aec891007c --- /dev/null +++ b/packages/design-system/src/components/N8nButton/Button.stories.ts @@ -0,0 +1,167 @@ +/* tslint:disable:variable-name */ +import N8nButton from './Button.vue'; +import { action } from '@storybook/addon-actions'; +import { StoryFn } from "@storybook/vue"; + +export default { + title: 'Atoms/Button', + component: N8nButton, + argTypes: { + type: { + control: 'select', + options: ['primary', 'secondary', 'tertiary', 'success', 'warning', 'danger'], + }, + size: { + control: { + type: 'select', + options: ['mini', 'small', 'medium', 'large', 'xlarge'], + }, + }, + float: { + type: 'select', + options: ['left', 'right'], + }, + }, + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/DxLbnIyMK8X0uLkUguFV4n/n8n-design-system_v1?node-id=5%3A1147', + }, + }, +}; + +const methods = { + onClick: action('click'), +}; + +const Template: StoryFn = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nButton, + }, + template: '', + methods, +}); + +export const Button = Template.bind({}); +Button.args = { + label: 'Button', +}; + +const AllSizesTemplate: StoryFn = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nButton, + }, + template: `
+ + + + + +
`, + methods, +}); + +const AllColorsTemplate: StoryFn = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nButton, + }, + template: `
+ + + + + + +
`, + methods, +}); + +const AllColorsAndSizesTemplate: StoryFn = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nButton, + }, + template: `
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
`, + methods, +}); + +export const Primary = AllSizesTemplate.bind({}); +Primary.args = { + type: 'primary', + label: 'Button', +}; + +export const Secondary = AllSizesTemplate.bind({}); +Secondary.args = { + type: 'secondary', + label: 'Button', +}; + +export const Tertiary = AllSizesTemplate.bind({}); +Tertiary.args = { + type: 'tertiary', + label: 'Button', +}; + +export const Success = AllSizesTemplate.bind({}); +Success.args = { + type: 'success', + label: 'Button', +}; + +export const Warning = AllSizesTemplate.bind({}); +Warning.args = { + type: 'warning', + label: 'Button', +}; + +export const Danger = AllSizesTemplate.bind({}); +Danger.args = { + type: 'danger', + label: 'Button', +}; + +export const Outline = AllColorsAndSizesTemplate.bind({}); +Outline.args = { + outline: true, + label: 'Button', +}; + +export const Text = AllColorsAndSizesTemplate.bind({}); +Text.args = { + text: true, + label: 'Button', +}; + +export const WithIcon = AllSizesTemplate.bind({}); +WithIcon.args = { + label: 'Button', + icon: 'plus-circle', +}; + diff --git a/packages/design-system/src/components/N8nButton/Button.vue b/packages/design-system/src/components/N8nButton/Button.vue index 3868d2839d..05033ba02a 100644 --- a/packages/design-system/src/components/N8nButton/Button.vue +++ b/packages/design-system/src/components/N8nButton/Button.vue @@ -1,62 +1,45 @@ - diff --git a/packages/design-system/src/components/N8nButton/__tests__/Button.spec.ts b/packages/design-system/src/components/N8nButton/__tests__/Button.spec.ts new file mode 100644 index 0000000000..e86cc1f3ed --- /dev/null +++ b/packages/design-system/src/components/N8nButton/__tests__/Button.spec.ts @@ -0,0 +1,63 @@ +import {render} from '@testing-library/vue'; +import N8nButton from "../Button.vue"; +import ElButton from "../overrides/ElButton.vue"; + +const slots = { + default: 'Button', +}; +const stubs = ['n8n-spinner', 'n8n-icon']; + +describe('components', () => { + describe('N8nButton', () => { + it('should render correctly', () => { + const wrapper = render(N8nButton, { + slots, + stubs, + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + + describe('props', () => { + describe('loading', () => { + it('should render loading spinner', () => { + const wrapper = render(N8nButton, { + props: { + loading: true, + }, + slots, + stubs, + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('icon', () => { + it('should render icon button', () => { + const wrapper = render(N8nButton, { + props: { + icon: 'plus-circle', + }, + slots, + stubs, + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + }); + + describe('overrides', () => { + it('should render correctly', () => { + const wrapper = render(ElButton, { + props: { + icon: 'plus-circle', + type: 'secondary', + }, + slots, + stubs, + }); + + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/packages/design-system/src/components/N8nButton/__tests__/__snapshots__/Button.spec.ts.snap b/packages/design-system/src/components/N8nButton/__tests__/__snapshots__/Button.spec.ts.snap new file mode 100644 index 0000000000..33ec8d07ad --- /dev/null +++ b/packages/design-system/src/components/N8nButton/__tests__/__snapshots__/Button.spec.ts.snap @@ -0,0 +1,12 @@ +// Vitest Snapshot v1 + +exports[`components > N8nButton > overrides > should render correctly 1`] = `""`; + +exports[`components > N8nButton > props > icon > should render icon button 1`] = `""`; + +exports[`components > N8nButton > props > loading > should render loading spinner 1`] = `""`; + +exports[`components > N8nButton > should render correctly 1`] = ` +"" +`; diff --git a/packages/design-system/src/components/N8nButton/index.d.ts b/packages/design-system/src/components/N8nButton/index.d.ts deleted file mode 100644 index ad6c9e62b9..0000000000 --- a/packages/design-system/src/components/N8nButton/index.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { N8nComponent, N8nComponentSize } from '../component'; - -/** Button type */ -export type ButtonType = 'primary' | 'outline' | 'light' | 'text'; - -/** Button themes */ -export type ButtonTheme = 'success' | 'warning' | 'danger'; - -/** Button Component */ -export declare class N8nButton extends N8nComponent { - /** Button text */ - label: string; - - /** Button title on hover */ - title: string; - - /** Color scheme */ - theme: ButtonTheme; - - /** Button size */ - size: N8nComponentSize; - - /** Button type */ - type: ButtonType; - - /** Determine whether it's a circular button */ - circle: boolean; - - /** Determine whether it's loading */ - loading: boolean; - - /** Disable the button */ - disabled: boolean; - - /** Button icon, accepts an icon name of font awesome icon component */ - icon: string; - - /** Full width */ - fullWidth: boolean; - - /** Float left or right */ - float: boolean; -} diff --git a/packages/design-system/src/components/N8nButton/overrides/ElButton.vue b/packages/design-system/src/components/N8nButton/overrides/ElButton.vue new file mode 100644 index 0000000000..0ef69b3ee7 --- /dev/null +++ b/packages/design-system/src/components/N8nButton/overrides/ElButton.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/design-system/src/components/N8nButton/overrides/index.ts b/packages/design-system/src/components/N8nButton/overrides/index.ts new file mode 100644 index 0000000000..c62be6ec63 --- /dev/null +++ b/packages/design-system/src/components/N8nButton/overrides/index.ts @@ -0,0 +1 @@ +export { default as N8nElButton } from './ElButton.vue'; 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 0000000000..0c7a315e19 --- /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/N8nIconButton/IconButton.stories.js b/packages/design-system/src/components/N8nIconButton/IconButton.stories.ts similarity index 77% rename from packages/design-system/src/components/N8nIconButton/IconButton.stories.js rename to packages/design-system/src/components/N8nIconButton/IconButton.stories.ts index fe99328783..a61c06bcc6 100644 --- a/packages/design-system/src/components/N8nIconButton/IconButton.stories.js +++ b/packages/design-system/src/components/N8nIconButton/IconButton.stories.ts @@ -1,5 +1,7 @@ +/* tslint:disable:variable-name */ import N8nIconButton from './IconButton.vue'; import { action } from '@storybook/addon-actions'; +import { StoryFn } from "@storybook/vue"; export default { title: 'Atoms/Icon Button', @@ -7,31 +9,12 @@ export default { argTypes: { type: { control: 'select', - options: ['primary', 'outline', 'light', 'text'], - }, - title: { - control: 'text', + options: ['primary', 'secondary', 'tertiary', 'success', 'warning', 'danger'], }, size: { control: { type: 'select', - options: ['small', 'medium', 'large', 'xlarge'], - }, - }, - loading: { - control: { - type: 'boolean', - }, - }, - icon: { - control: { - type: 'text', - }, - }, - theme: { - control: { - type: 'select', - options: ['success', 'warning', 'danger'], + options: ['mini', 'small', 'medium', 'large', 'xlarge'], }, }, }, @@ -44,7 +27,7 @@ const methods = { onClick: action('click'), }; -const Template = (args, { argTypes }) => ({ +const Template: StoryFn = (args, { argTypes }) => ({ props: Object.keys(argTypes), components: { N8nIconButton, @@ -59,7 +42,7 @@ Button.args = { title: 'my title', }; -const ManyTemplate = (args, { argTypes }) => ({ +const ManyTemplate: StoryFn = (args, { argTypes }) => ({ props: Object.keys(argTypes), components: { N8nIconButton, @@ -80,7 +63,8 @@ export const Outline = ManyTemplate.bind({}); Outline.args = { icon: 'plus', title: 'my title', - type: 'outline', + type: 'primary', + outline: true, }; export const Light = ManyTemplate.bind({}); diff --git a/packages/design-system/src/components/N8nIconButton/IconButton.vue b/packages/design-system/src/components/N8nIconButton/IconButton.vue index b2d6649ebe..cf1482712d 100644 --- a/packages/design-system/src/components/N8nIconButton/IconButton.vue +++ b/packages/design-system/src/components/N8nIconButton/IconButton.vue @@ -1,14 +1,8 @@ - @@ -23,9 +17,7 @@ export default { props: { type: { type: String, - }, - title: { - type: String, + default: 'primary', }, size: { type: String, @@ -35,16 +27,73 @@ export default { type: Boolean, default: false, }, + outline: { + type: Boolean, + default: false, + }, + text: { + type: Boolean, + default: false, + }, disabled: { type: Boolean, default: false, }, + active: { + type: Boolean, + default: false, + }, icon: { + type: [String, Array], required: true, }, - theme: { + float: { type: String, + validator: (value: string): boolean => + ['left', 'right'].includes(value), + }, + circle: { + type: Boolean, + default: true, + }, + circle: { + type: Boolean, + default: true, }, }, }; + + diff --git a/packages/design-system/src/components/N8nLink/Link.vue b/packages/design-system/src/components/N8nLink/Link.vue index b8a5ce55a0..8db00ff826 100644 --- a/packages/design-system/src/components/N8nLink/Link.vue +++ b/packages/design-system/src/components/N8nLink/Link.vue @@ -1,6 +1,6 @@ diff --git a/packages/editor-ui/src/components/SaveButton.vue b/packages/editor-ui/src/components/SaveButton.vue index c8e6f074c4..b2ba5bad85 100644 --- a/packages/editor-ui/src/components/SaveButton.vue +++ b/packages/editor-ui/src/components/SaveButton.vue @@ -49,6 +49,7 @@ export default Vue.extend({ 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 0000000000..345b928ff0 --- /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 445c6645f4..84f059a350 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, MAPPING_PARAMS, 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']; @@ -71,6 +71,12 @@ 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; +} + export function isCommunityPackageName(packageName: string): boolean { COMMUNITY_PACKAGE_NAME_REGEX.lastIndex = 0; // Community packages names start with <@username/>n8n-nodes- not followed by word 'base' diff --git a/packages/editor-ui/src/components/mixins/nodeHelpers.ts b/packages/editor-ui/src/components/mixins/nodeHelpers.ts index 75c57cfc5f..6fae0e4a9b 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 0000000000..d558bd7d84 --- /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 6fc7e5f83e..0b1844da47 100644 --- a/packages/editor-ui/src/components/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/components/mixins/workflowHelpers.ts @@ -30,6 +30,7 @@ import { IExecuteData, INodeConnection, IWebhookDescription, + PinData, } from 'n8n-workflow'; import { @@ -69,7 +70,7 @@ export const workflowHelpers = mixins( source: null, } as IExecuteData; - if (parentNode.length) { + if (parentNode.length) { // Add the input data to be able to also resolve the short expression format // which does not use the node name const parentNodeName = parentNode[0]; @@ -78,6 +79,7 @@ export const workflowHelpers = mixins( if (workflowRunData === null) { return executeData; } + if (!workflowRunData[parentNodeName] || workflowRunData[parentNodeName].length <= runIndex || !workflowRunData[parentNodeName][runIndex].hasOwnProperty('data') || @@ -108,7 +110,7 @@ export const workflowHelpers = mixins( }, // Returns connectionInputData to be able to execute an expression. connectionInputData (parentNode: string[], currentNode: string, inputName: string, runIndex: number, nodeConnection: INodeConnection = { sourceIndex: 0, destinationIndex: 0 }): INodeExecutionData[] | null { - let connectionInputData = null; + let connectionInputData: INodeExecutionData[] | null = null; const executeData = this.executeData(parentNode, currentNode, inputName, runIndex); if (parentNode.length) { if (!Object.keys(executeData.data).length || executeData.data[inputName].length <= nodeConnection.sourceIndex) { @@ -131,6 +133,35 @@ export const workflowHelpers = mixins( } } + const parentPinData = parentNode.reduce((acc: INodeExecutionData[], parentNodeName, index) => { + const pinData = this.$store.getters['pinDataByNodeName'](parentNodeName); + + if (pinData) { + acc.push({ + json: pinData[0], + pairedItem: { + item: index, + input: 1, + }, + }); + } + + return acc; + }, []); + + if (parentPinData.length > 0) { + if (connectionInputData && connectionInputData.length > 0) { + parentPinData.forEach((parentPinDataEntry) => { + connectionInputData![0].json = { + ...connectionInputData![0].json, + ...parentPinDataEntry.json, + }; + }); + } else { + connectionInputData = parentPinData; + } + } + return connectionInputData; }, @@ -328,6 +359,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, @@ -473,7 +505,10 @@ export const workflowHelpers = mixins( const workflowRunData = this.$store.getters.getWorkflowRunData as IRunData | null; let runIndexParent = 0; if (workflowRunData !== null && parentNode.length) { - runIndexParent = workflowRunData[parentNode[0]].length -1; + const firstParentWithWorkflowRunData = parentNode.find((parentNodeName) => workflowRunData[parentNodeName]); + if (firstParentWithWorkflowRunData) { + runIndexParent = workflowRunData[firstParentWithWorkflowRunData].length - 1; + } } const nodeConnection = workflow.getNodeConnectionIndexes(activeNode!.name, parentNode[0]); @@ -490,6 +525,34 @@ export const workflowHelpers = mixins( runExecutionData = executionData.data; } + parentNode.forEach((parentNodeName) => { + const pinData: PinData[string] = this.$store.getters['pinDataByNodeName'](parentNodeName); + + if (pinData) { + runExecutionData = { + ...runExecutionData, + resultData: { + ...runExecutionData.resultData, + runData: { + ...runExecutionData.resultData.runData, + [parentNodeName]: [ + { + startTime: new Date().valueOf(), + executionTime: 0, + source: [], + data: { + main: [ + pinData.map((data) => ({ json: data })), + ], + }, + }, + ], + }, + }, + }; + } + }); + if (connectionInputData === null) { connectionInputData = []; } @@ -509,7 +572,6 @@ export const workflowHelpers = mixins( }, resolveExpression(expression: string, siblingParameters: INodeParameters = {}) { - const parameters = { '__xxxxxxx__': expression, ...siblingParameters, diff --git a/packages/editor-ui/src/components/mixins/workflowRun.ts b/packages/editor-ui/src/components/mixins/workflowRun.ts index ee243467ce..135823446d 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 12b8c59af4..5ec4996ddf 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-'; @@ -55,6 +57,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/'; export const NPM_COMMUNITY_NODE_SEARCH_API_URL = `https://api.npms.io/v2/`; export const NPM_PACKAGE_DOCS_BASE_URL = `https://www.npmjs.com/package/`; export const NPM_KEYWORD_SEARCH_URL = `https://www.npmjs.com/search?q=keywords%3An8n-community-node-package`; @@ -95,6 +98,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'; @@ -106,6 +110,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'; @@ -210,6 +224,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 LOCAL_STORAGE_MAPPING_FLAG = 'N8N_MAPPING_ONBOARDED'; export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename='; @@ -262,4 +278,14 @@ export enum VIEWS { COMMUNITY_NODES = "CommunityNodes", } +export const TEST_PIN_DATA = [ + { + name: "First item", + code: 1, + }, + { + name: "Second item", + code: 2, + }, +]; export const MAPPING_PARAMS = [`$evaluateExpression`, `$item`, `$jmespath`, `$node`, `$binary`, `$data`, `$env`, `$json`, `$now`, `$parameters`, `$position`, `$resumeWebhookUrl`, `$runIndex`, `$today`, `$workflow`, '$parameter']; 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 0000000000..7eccde2710 --- /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 d009e7b0b0..877b1eee81 100644 --- a/packages/editor-ui/src/modules/ui.ts +++ b/packages/editor-ui/src/modules/ui.ts @@ -108,6 +108,10 @@ const module: Module = { }, output: { displayMode: 'table', + editMode: { + enabled: false, + value: '', + }, }, focusedMappableInput: '', mappingTelemetry: {}, @@ -147,6 +151,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, focusedMappableInput: (state: IUiState) => state.ndv.focusedMappableInput, isDraggableDragging: (state: IUiState) => state.draggable.isDragging, @@ -198,6 +203,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/n8n-theme.scss b/packages/editor-ui/src/n8n-theme.scss index 4cdf96832e..c44517d37b 100644 --- a/packages/editor-ui/src/n8n-theme.scss +++ b/packages/editor-ui/src/n8n-theme.scss @@ -196,7 +196,7 @@ &, &:hover, &:focus { - border-radius: 20px; + border-radius: var(--border-radius-base); color: var(--color-text-dark); background-color: var(--color-background-base); border-color: var(--color-foreground-base); diff --git a/packages/editor-ui/src/plugins/components.ts b/packages/editor-ui/src/plugins/components.ts index 663fd0dc05..89f5d3b957 100644 --- a/packages/editor-ui/src/plugins/components.ts +++ b/packages/editor-ui/src/plugins/components.ts @@ -49,6 +49,9 @@ import { N8nAvatar, N8nActionToggle, N8nButton, + N8nElButton, + N8nCallout, + N8nPanelCallout, N8nCard, N8nIcon, N8nIconButton, @@ -87,7 +90,10 @@ Vue.use(N8nInfoAccordion); Vue.use(N8nActionBox); Vue.use(N8nActionToggle); Vue.use(N8nAvatar); -Vue.use(N8nButton); +Vue.component('n8n-button', N8nButton); +Vue.component('el-button', N8nElButton); +Vue.component('n8n-callout', N8nCallout); +Vue.component('n8n-panel-callout', N8nPanelCallout); Vue.component('n8n-card', N8nCard); Vue.component('n8n-form-box', N8nFormBox); Vue.component('n8n-form-inputs', N8nFormInputs); @@ -161,7 +167,6 @@ Vue.prototype.$alert = async (message: string, configOrTitle: string | ElMessage let temp = config || (typeof configOrTitle === 'object' ? configOrTitle : {}); temp = { ...temp, - roundButton: true, cancelButtonClass: 'btn--cancel', confirmButtonClass: 'btn--confirm', }; @@ -176,7 +181,6 @@ Vue.prototype.$confirm = async (message: string, configOrTitle: string | ElMessa let temp = config || (typeof configOrTitle === 'object' ? configOrTitle : {}); temp = { ...temp, - roundButton: true, cancelButtonClass: 'btn--cancel', confirmButtonClass: 'btn--confirm', distinguishCancelAndClose: true, @@ -194,7 +198,6 @@ Vue.prototype.$prompt = async (message: string, configOrTitle: string | ElMessag let temp = config || (typeof configOrTitle === 'object' ? configOrTitle : {}); temp = { ...temp, - roundButton: true, cancelButtonClass: 'btn--cancel', confirmButtonClass: 'btn--confirm', }; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 4124da3a11..6e94459cfa 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -14,6 +14,7 @@ "generic.communityNode.tooltip": "This is a node from our community. It's part of the {packageName} package. Learn more", "generic.delete": "Delete", "generic.copy": "Copy", + "generic.or": "or", "generic.clickToCopy": "Click to copy", "generic.copiedToClipboard": "Copied to clipboard", "generic.beta": "beta", @@ -343,7 +344,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...", @@ -357,7 +361,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!", @@ -365,6 +371,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", @@ -378,6 +398,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", @@ -644,14 +666,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", @@ -664,6 +697,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 17f3ac53d0..428dafa1fa 100644 --- a/packages/editor-ui/src/plugins/icons.ts +++ b/packages/editor-ui/src/plugins/icons.ts @@ -92,6 +92,7 @@ import { faTasks, faTerminal, faThLarge, + faThumbtack, faTimes, faTimesCircle, faTrash, @@ -204,6 +205,7 @@ addIcon(faTable); addIcon(faTasks); addIcon(faTerminal); addIcon(faThLarge); +addIcon(faThumbtack); addIcon(faTimes); addIcon(faTimesCircle); addIcon(faTrash); diff --git a/packages/editor-ui/src/store.ts b/packages/editor-ui/src/store.ts index 1c22c3b923..6bf8ba14d9 100644 --- a/packages/editor-ui/src/store.ts +++ b/packages/editor-ui/src/store.ts @@ -1,8 +1,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 +16,7 @@ import { IRunData, ITaskData, IWorkflowSettings, + PinData, } from 'n8n-workflow'; import { @@ -40,6 +43,8 @@ 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"; +import {dataPinningEventBus} from "@/event-bus/data-pinning-event-bus"; import communityNodes from './modules/communityNodes'; import { isCommunityPackageName } from './components/helpers'; @@ -90,6 +95,7 @@ const state: IRootState = { nodes: [], settings: {}, tags: [], + pinData: {}, }, sidebarMenuItems: [], instanceId: '', @@ -114,13 +120,13 @@ export const store = new Vuex.Store({ state, mutations: { // Active Actions - addActiveAction (state, action: string) { + addActiveAction(state, action: string) { if (!state.activeActions.includes(action)) { state.activeActions.push(action); } }, - removeActiveAction (state, action: string) { + removeActiveAction(state, action: string) { const actionIndex = state.activeActions.indexOf(action); if (actionIndex !== -1) { state.activeActions.splice(actionIndex, 1); @@ -128,7 +134,7 @@ export const store = new Vuex.Store({ }, // Active Executions - addActiveExecution (state, newActiveExecution: IExecutionsCurrentSummaryExtended) { + addActiveExecution(state, newActiveExecution: IExecutionsCurrentSummaryExtended) { // Check if the execution exists already const activeExecution = state.activeExecutions.find(execution => { return execution.id === newActiveExecution.id; @@ -144,7 +150,7 @@ export const store = new Vuex.Store({ state.activeExecutions.unshift(newActiveExecution); }, - finishActiveExecution (state, finishedActiveExecution: IPushDataExecutionFinished) { + finishActiveExecution(state, finishedActiveExecution: IPushDataExecutionFinished) { // Find the execution to set to finished const activeExecution = state.activeExecutions.find(execution => { return execution.id === finishedActiveExecution.executionId; @@ -162,22 +168,22 @@ export const store = new Vuex.Store({ Vue.set(activeExecution, 'finished', finishedActiveExecution.data.finished); Vue.set(activeExecution, 'stoppedAt', finishedActiveExecution.data.stoppedAt); }, - setActiveExecutions (state, newActiveExecutions: IExecutionsCurrentSummaryExtended[]) { + setActiveExecutions(state, newActiveExecutions: IExecutionsCurrentSummaryExtended[]) { Vue.set(state, 'activeExecutions', newActiveExecutions); }, // Active Workflows - setActiveWorkflows (state, newActiveWorkflows: string[]) { + setActiveWorkflows(state, newActiveWorkflows: string[]) { state.activeWorkflows = newActiveWorkflows; }, - setWorkflowActive (state, workflowId: string) { + setWorkflowActive(state, workflowId: string) { state.stateIsDirty = false; const index = state.activeWorkflows.indexOf(workflowId); if (index === -1) { state.activeWorkflows.push(workflowId); } }, - setWorkflowInactive (state, workflowId: string) { + setWorkflowInactive(state, workflowId: string) { const index = state.activeWorkflows.indexOf(workflowId); if (index !== -1) { state.activeWorkflows.splice(index, 1); @@ -185,15 +191,15 @@ export const store = new Vuex.Store({ }, // Set state condition dirty or not // ** Dirty: if current workflow state has been synchronized with database AKA has it been saved - setStateDirty (state, dirty : boolean) { + setStateDirty(state, dirty: boolean) { state.stateIsDirty = dirty; }, // Selected Nodes - addSelectedNode (state, node: INodeUi) { + addSelectedNode(state, node: INodeUi) { state.selectedNodes.push(node); }, - removeNodeFromSelection (state, node: INodeUi) { + removeNodeFromSelection(state, node: INodeUi) { let index; for (index in state.selectedNodes) { if (state.selectedNodes[index].name === node.name) { @@ -202,17 +208,38 @@ export const store = new Vuex.Store({ } } }, - resetSelectedNodes (state) { + resetSelectedNodes(state) { Vue.set(state, 'selectedNodes', []); }, + // Pin data + pinData(state, payload: { node: INodeUi, data: PinData[string] }) { + if (state.workflow.pinData) { + Vue.set(state.workflow.pinData, payload.node.name, payload.data); + } + + state.stateIsDirty = true; + + dataPinningEventBus.$emit('pin-data', { [payload.node.name]: payload.data }); + }, + 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; + + dataPinningEventBus.$emit('unpin-data', { [payload.node.name]: undefined }); + }, + // Active - setActive (state, newActive: boolean) { + setActive(state, newActive: boolean) { state.workflow.active = newActive; }, // Connections - addConnection (state, data) { + addConnection(state, data) { if (data.connection.length !== 2) { // All connections need two entries // TODO: Check if there is an error or whatever that is supposed to be returned @@ -260,7 +287,7 @@ export const store = new Vuex.Store({ } }, - removeConnection (state, data) { + removeConnection(state, data) { const sourceData = data.connection[0]; const destinationData = data.connection[1]; @@ -285,13 +312,13 @@ export const store = new Vuex.Store({ } }, - removeAllConnections (state, data) { + removeAllConnections(state, data) { if (data && data.setStateDirty === true) { state.stateIsDirty = true; } state.workflow.connections = {}; }, - removeAllNodeConnection (state, node: INodeUi) { + removeAllNodeConnection(state, node: INodeUi) { state.stateIsDirty = true; // Remove all source connections if (state.workflow.connections.hasOwnProperty(node.name)) { @@ -320,7 +347,7 @@ export const store = new Vuex.Store({ } }, - renameNodeSelectedAndExecution (state, nameData) { + renameNodeSelectedAndExecution(state, nameData) { state.stateIsDirty = true; // If node has any WorkflowResultData rename also that one that the data // does still get displayed also after node got renamed @@ -336,9 +363,14 @@ 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) { + resetAllNodesIssues(state) { state.workflow.nodes.forEach((node) => { node.issues = undefined; }); @@ -346,7 +378,7 @@ export const store = new Vuex.Store({ return true; }, - setNodeIssue (state, nodeIssueData: INodeIssueData) { + setNodeIssue(state, nodeIssueData: INodeIssueData) { const node = state.workflow.nodes.find(node => { return node.name === nodeIssueData.node; @@ -382,7 +414,7 @@ export const store = new Vuex.Store({ }, // Name - setWorkflowName (state, data) { + setWorkflowName(state, data) { if (data.setStateDirty === true) { state.stateIsDirty = true; } @@ -390,7 +422,7 @@ export const store = new Vuex.Store({ }, // replace invalid credentials in workflow - replaceInvalidWorkflowCredentials(state, {credentials, invalid, type }) { + replaceInvalidWorkflowCredentials(state, {credentials, invalid, type}) { state.workflow.nodes.forEach((node) => { if (!node.credentials || !node.credentials[type]) { return; @@ -403,7 +435,7 @@ export const store = new Vuex.Store({ } if (nodeCredentials.id === null) { - if (nodeCredentials.name === invalid.name){ + if (nodeCredentials.name === invalid.name) { node.credentials[type] = credentials; } return; @@ -416,7 +448,7 @@ export const store = new Vuex.Store({ }, // Nodes - addNode (state, nodeData: INodeUi) { + addNode(state, nodeData: INodeUi) { if (!nodeData.hasOwnProperty('name')) { // All nodes have to have a name // TODO: Check if there is an error or whatever that is supposed to be returned @@ -425,9 +457,13 @@ export const store = new Vuex.Store({ state.workflow.nodes.push(nodeData); }, - removeNode (state, node: INodeUi) { + 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,14 +472,19 @@ export const store = new Vuex.Store({ } } }, - removeAllNodes (state, data) { + removeAllNodes(state, data) { if (data.setStateDirty === true) { state.stateIsDirty = true; } + + if (data.removePinData) { + state.workflow.pinData = {}; + } + state.workflow.nodes.splice(0, state.workflow.nodes.length); state.nodeMetadata = {}; }, - updateNodeProperties (state, updateInformation: INodeUpdatePropertiesInformation) { + updateNodeProperties(state, updateInformation: INodeUpdatePropertiesInformation) { // Find the node that should be updated const node = state.workflow.nodes.find(node => { return node.name === updateInformation.name; @@ -456,7 +497,7 @@ export const store = new Vuex.Store({ } } }, - setNodeValue (state, updateInformation: IUpdateInformation) { + setNodeValue(state, updateInformation: IUpdateInformation) { // Find the node that should be updated const node = state.workflow.nodes.find(node => { return node.name === updateInformation.name; @@ -469,7 +510,7 @@ export const store = new Vuex.Store({ state.stateIsDirty = true; Vue.set(node, updateInformation.key, updateInformation.value); }, - setNodeParameters (state, updateInformation: IUpdateInformation) { + setNodeParameters(state, updateInformation: IUpdateInformation) { // Find the node that should be updated const node = state.workflow.nodes.find(node => { return node.name === updateInformation.name; @@ -489,70 +530,71 @@ export const store = new Vuex.Store({ }, // Node-Index - addToNodeIndex (state, nodeName: string) { + addToNodeIndex(state, nodeName: string) { state.nodeIndex.push(nodeName); }, - setNodeIndex (state, newData: { index: number, name: string | null}) { + setNodeIndex(state, newData: { index: number, name: string | null }) { state.nodeIndex[newData.index] = newData.name; }, - resetNodeIndex (state) { + resetNodeIndex(state) { Vue.set(state, 'nodeIndex', []); }, // Node-View - setNodeViewMoveInProgress (state, value: boolean) { + setNodeViewMoveInProgress(state, value: boolean) { state.nodeViewMoveInProgress = value; }, - setNodeViewOffsetPosition (state, data) { + setNodeViewOffsetPosition(state, data) { state.nodeViewOffsetPosition = data.newOffset; }, - setNodeTypes (state, nodeTypes: INodeTypeDescription[]) { + // Node-Types + setNodeTypes(state, nodeTypes: INodeTypeDescription[]) { Vue.set(state, 'nodeTypes', nodeTypes); }, // Active Execution - setExecutingNode (state, executingNode: string) { + setExecutingNode(state, executingNode: string) { state.executingNode = executingNode; }, - setExecutionWaitingForWebhook (state, newWaiting: boolean) { + setExecutionWaitingForWebhook(state, newWaiting: boolean) { state.executionWaitingForWebhook = newWaiting; }, - setActiveExecutionId (state, executionId: string | null) { + setActiveExecutionId(state, executionId: string | null) { state.executionId = executionId; }, // Push Connection - setPushConnectionActive (state, newActive: boolean) { + setPushConnectionActive(state, newActive: boolean) { state.pushConnectionActive = newActive; }, // Webhooks - setUrlBaseWebhook (state, urlBaseWebhook: string) { + setUrlBaseWebhook(state, urlBaseWebhook: string) { Vue.set(state, 'urlBaseWebhook', urlBaseWebhook); }, - setEndpointWebhook (state, endpointWebhook: string) { + setEndpointWebhook(state, endpointWebhook: string) { Vue.set(state, 'endpointWebhook', endpointWebhook); }, - setEndpointWebhookTest (state, endpointWebhookTest: string) { + setEndpointWebhookTest(state, endpointWebhookTest: string) { Vue.set(state, 'endpointWebhookTest', endpointWebhookTest); }, - setSaveDataErrorExecution (state, newValue: string) { + setSaveDataErrorExecution(state, newValue: string) { Vue.set(state, 'saveDataErrorExecution', newValue); }, - setSaveDataSuccessExecution (state, newValue: string) { + setSaveDataSuccessExecution(state, newValue: string) { Vue.set(state, 'saveDataSuccessExecution', newValue); }, - setSaveManualExecutions (state, saveManualExecutions: boolean) { + setSaveManualExecutions(state, saveManualExecutions: boolean) { Vue.set(state, 'saveManualExecutions', saveManualExecutions); }, - setTimezone (state, timezone: string) { + setTimezone(state, timezone: string) { Vue.set(state, 'timezone', timezone); }, - setExecutionTimeout (state, executionTimeout: number) { + setExecutionTimeout(state, executionTimeout: number) { Vue.set(state, 'executionTimeout', executionTimeout); }, - setMaxExecutionTimeout (state, maxExecutionTimeout: number) { + setMaxExecutionTimeout(state, maxExecutionTimeout: number) { Vue.set(state, 'maxExecutionTimeout', maxExecutionTimeout); }, setVersionCli(state, version: string) { @@ -570,25 +612,25 @@ export const store = new Vuex.Store({ setDefaultLocale(state, locale: string) { Vue.set(state, 'defaultLocale', locale); }, - setActiveNode (state, nodeName: string) { + setActiveNode(state, nodeName: string) { state.activeNode = nodeName; }, - setActiveCredentialType (state, activeCredentialType: string) { + setActiveCredentialType(state, activeCredentialType: string) { state.activeCredentialType = activeCredentialType; }, - setLastSelectedNode (state, nodeName: string) { + setLastSelectedNode(state, nodeName: string) { state.lastSelectedNode = nodeName; }, - setLastSelectedNodeOutputIndex (state, outputIndex: number | null) { + setLastSelectedNodeOutputIndex(state, outputIndex: number | null) { state.lastSelectedNodeOutputIndex = outputIndex; }, - setWorkflowExecutionData (state, workflowResultData: IExecutionResponse | null) { + setWorkflowExecutionData(state, workflowResultData: IExecutionResponse | null) { state.workflowExecutionData = workflowResultData; }, - addNodeExecutionData (state, pushData: IPushDataNodeExecuteAfter): void { + addNodeExecutionData(state, pushData: IPushDataNodeExecuteAfter): void { if (state.workflowExecutionData === null) { throw new Error('The "workflowExecutionData" is not initialized!'); } @@ -597,7 +639,7 @@ export const store = new Vuex.Store({ } state.workflowExecutionData.data.resultData.runData[pushData.nodeName].push(pushData.data); }, - clearNodeExecutionData (state, nodeName: string): void { + clearNodeExecutionData(state, nodeName: string): void { if (state.workflowExecutionData === null) { return; } @@ -605,19 +647,25 @@ export const store = new Vuex.Store({ Vue.delete(state.workflowExecutionData.data.resultData.runData, nodeName); }, - setWorkflowSettings (state, workflowSettings: IWorkflowSettings) { + setWorkflowSettings(state, workflowSettings: IWorkflowSettings) { Vue.set(state.workflow, 'settings', workflowSettings); }, - setWorkflowTagIds (state, tags: string[]) { + setWorkflowPinData(state, pinData: PinData) { + Vue.set(state.workflow, 'pinData', pinData); + + dataPinningEventBus.$emit('pin-data', pinData); + }, + + setWorkflowTagIds(state, tags: string[]) { Vue.set(state.workflow, 'tags', tags); }, - addWorkflowTagIds (state, tags: string[]) { + addWorkflowTagIds(state, tags: string[]) { Vue.set(state.workflow, 'tags', [...new Set([...(state.workflow.tags || []), ...tags])]); }, - removeWorkflowTagId (state, tagId: string) { + removeWorkflowTagId(state, tagId: string) { const tags = state.workflow.tags as string[]; const updated = tags.filter((id: string) => id !== tagId); @@ -625,7 +673,7 @@ export const store = new Vuex.Store({ }, // Workflow - setWorkflow (state, workflow: IWorkflowDb) { + setWorkflow(state, workflow: IWorkflowDb) { Vue.set(state, 'workflow', workflow); if (!state.workflow.hasOwnProperty('active')) { @@ -651,7 +699,7 @@ export const store = new Vuex.Store({ } }, - updateNodeTypes (state, nodeTypes: INodeTypeDescription[]) { + updateNodeTypes(state, nodeTypes: INodeTypeDescription[]) { const oldNodesToKeep = state.nodeTypes.filter(node => !nodeTypes.find(n => n.name === node.name && n.version.toString() === node.version.toString())); const newNodesState = [...oldNodesToKeep, ...nodeTypes]; @@ -673,7 +721,7 @@ export const store = new Vuex.Store({ }, getters: { executedNode: (state): string | undefined => { - return state.workflowExecutionData? state.workflowExecutionData.executedNode: undefined; + return state.workflowExecutionData ? state.workflowExecutionData.executedNode : undefined; }, activeCredentialType: (state): string | null => { return state.activeCredentialType; @@ -729,7 +777,7 @@ export const store = new Vuex.Store({ return `${state.urlBaseWebhook}${state.endpointWebhookTest}`; }, - getStateIsDirty: (state) : boolean => { + getStateIsDirty: (state): boolean => { return state.stateIsDirty; }, @@ -832,8 +880,8 @@ export const store = new Vuex.Store({ allNodes: (state): INodeUi[] => { return state.workflow.nodes; }, - nodesByName: (state: IRootState): {[name: string]: INodeUi} => { - return state.workflow.nodes.reduce((accu: {[name: string]: INodeUi}, node) => { + nodesByName: (state: IRootState): { [name: string]: INodeUi } => { + return state.workflow.nodes.reduce((accu: { [name: string]: INodeUi }, node) => { accu[node.name] = node; return accu; }, {}); @@ -853,11 +901,32 @@ export const store = new Vuex.Store({ allNodeTypes: (state): INodeTypeDescription[] => { 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. */ nativelyNumberSuffixedDefaults: (_, getters): string[] => { - const { allNodeTypes } = getters as { + const {allNodeTypes} = getters as { allNodeTypes: Array; }; diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 4ad315ac0f..9de746dd9e 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -74,7 +74,12 @@ :class="['add-sticky-button', showStickyButton ? 'visible-button' : '']" @click="nodeTypeSelected(STICKY_NODE_TYPE)" > - + @@ -84,33 +89,27 @@ @closeNodeCreator="closeNodeCreator" />
- - - - + icon="undo" + />
@@ -196,6 +195,7 @@ import { TelemetryHelpers, ITelemetryTrackProperties, IWorkflowBase, + PinData, } from 'n8n-workflow'; import { ICredentialsResponse, @@ -220,6 +220,7 @@ import { import '../plugins/N8nCustomConnectorType'; import '../plugins/PlusEndpointType'; +import {dataPinningEventBus} from "@/event-bus/data-pinning-event-bus"; interface AddNodeOptions { position?: XYPosition; @@ -549,6 +550,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(() => { @@ -556,6 +558,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 }); @@ -610,6 +613,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(); }); @@ -678,6 +686,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); @@ -1312,6 +1321,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'); @@ -2077,6 +2090,10 @@ export default mixins( // so if we do not connect we have to save the connection manually this.$store.commit('addConnection', connectionProperties); } + + setTimeout(() => { + this.addPinDataConnections(this.$store.getters.pinData); + }); }, __removeConnection (connection: [IConnection, IConnection], removeVisualConnection = false) { if (removeVisualConnection === true) { @@ -2168,6 +2185,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 @@ -2271,6 +2296,7 @@ export default mixins( if (connection) { const output = outputMap[sourceOutputIndex][targetNodeName][targetInputIndex]; + if (!output || !output.total) { CanvasHelpers.resetConnection(connection); } @@ -2832,7 +2858,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); @@ -2940,6 +2966,31 @@ export default mixins( await this.importWorkflowData(workflowData); } }, + addPinDataConnections(pinData: PinData) { + Object.keys(pinData).forEach((nodeName) => { + // @ts-ignore + const connections = this.instance.getConnections({ + source: NODE_NAME_PREFIX + this.getNodeIndex(nodeName), + }) as Connection[]; + + connections.forEach((connection) => { + CanvasHelpers.addConnectionOutputSuccess(connection, { + total: pinData[nodeName].length, + iterations: 0, + }); + }); + }); + }, + removePinDataConnections(pinData: PinData) { + Object.keys(pinData).forEach((nodeName) => { + // @ts-ignore + const connections = this.instance.getConnections({ + source: NODE_NAME_PREFIX + this.getNodeIndex(nodeName), + }) as Connection[]; + + connections.forEach(CanvasHelpers.resetConnection); + }); + }, }, async mounted () { @@ -2989,10 +3040,14 @@ export default mixins( setTimeout(() => { this.$store.dispatch('users/showPersonalizationSurvey'); this.checkForNewVersions(); + this.addPinDataConnections(this.$store.getters.pinData); }, 0); }); this.$externalHooks().run('nodeView.mount'); + + dataPinningEventBus.$on('pin-data', this.addPinDataConnections); + dataPinningEventBus.$on('unpin-data', this.removePinDataConnections); }, destroyed () { @@ -3002,6 +3057,9 @@ export default mixins( this.$root.$off('newWorkflow', this.newWorkflow); this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent); this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent); + + dataPinningEventBus.$off('pin-data', this.addPinDataConnections); + dataPinningEventBus.$off('unpin-data', this.removePinDataConnections); }, }); @@ -3013,8 +3071,8 @@ export default mixins( position: fixed; left: $--sidebar-width + $--zoom-menu-margin; - width: 200px; - bottom: 45px; + width: 210px; + bottom: 44px; line-height: 25px; color: #444; padding-right: 5px; @@ -3026,6 +3084,16 @@ export default mixins( button { border: var(--border-base); } + + > * { + + * { + margin-left: var(--spacing-3xs); + } + + &:hover { + transform: scale(1.1); + } + } } .regular-zoom-menu { @@ -3198,23 +3266,6 @@ export default mixins( text-align: right; } -.button-white { - border: none; - padding: 0.3em; - margin: 0 0.1em; - border-radius: 3px; - font-size: 1.2em; - background: #fff; - width: 40px; - height: 40px; - color: #666; - cursor: pointer; - - &:hover { - transform: scale(1.1); - } -} - .connection-actions { &:hover { display: block !important; diff --git a/packages/editor-ui/vue.config.js b/packages/editor-ui/vue.config.js index 35338dd497..db1cd795fc 100644 --- a/packages/editor-ui/vue.config.js +++ b/packages/editor-ui/vue.config.js @@ -1,3 +1,4 @@ +const path = require('path'); const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin'); module.exports = { @@ -27,6 +28,12 @@ module.exports = { plugins: [ new MonacoWebpackPlugin({ languages: ['javascript', 'json', 'typescript'] }), ], + resolve: { + alias: { + 'element-ui/packages/button': path.resolve(__dirname, '..', 'design-system/src/components/N8nButton/overrides/ElButton.vue'), + 'element-ui/lib/button': path.resolve(__dirname, '..', 'design-system/src/components/N8nButton/overrides/ElButton.vue'), + }, + }, }, css: { loaderOptions: { diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 2b701ed9a9..fc9c791f91 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -839,6 +839,11 @@ export interface INode { parameters: INodeParameters; credentials?: INodeCredentials; webhookId?: string; + pinData?: IDataObject; +} + +export interface PinData { + [nodeName: string]: IDataObject[]; } export interface INodes { @@ -1323,6 +1328,7 @@ export interface IRunExecutionData { resultData: { error?: ExecutionError; runData: IRunData; + pinData?: PinData; lastNodeExecuted?: string; }; executionData?: { @@ -1396,6 +1402,7 @@ export interface IWorkflowBase { connections: IConnections; settings?: IWorkflowSettings; staticData?: IDataObject; + pinData?: PinData; } export interface IWorkflowCredentials { @@ -1516,6 +1523,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 723709e9f6..366f760d7f 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 72f9054cfc..3c29e5fa15 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 00e8f02993..508f8b2598 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) {