feat(editor): Add data pinning functionality (#3511)
* 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.
* 🔥 Remove conflict markers
* ✏️ Update i18n keys
* ⚡ Add JSON validation
* 🗃️ Add `pinData` column to `workflow_entity`
* 📘 Tighten type
* ⚡ Make `pinData` column nullable
* ⚡ Adjust workflow endpoints for pin data
* 📘 Improve types
* ✏️ Improve wording
* Inject pindata into items flow (#3420)
* ⚡ Inject pin data - Second approach
* 🔥 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.
* ⏪ Undo button change
* 🐛 Fix runNode call
Adjust per update in bdb84130d6
* 🧪 Fix workflow tests
* 🐛 More merge conflict fixes
* feat: Added pin/unpin button and store mutations.
* feat: Size check. Various design and ux improvements.
* ⚡ Add transformer
* ⚡ Hoist pin data
* ⚡ Adjust endpoints for hoisted pin data
* 📘 Expand interface
* 🐛 Fix stray array
* 👕 Fix build
* 👕 Add lint exception
* 👕 Fix header
* 🎨 Add color secondary tints
* ✨ Create `HeaderMessage` component
* ⚡ Adjust `InfoTip` component
* ✨ Add `HeaderMessage` to `RunData`
* 🐛 Fix console error
* 👕 Fix lint
* ⚡ Consolidate `HeaderMessage` and `Callout`
* ⏪ Undo `InfoTip` changes
* 🔥 Remove duplicate icons
* ⚡ Simplify template
* 🎨 Change cursor for action text
* 👕 Fix lint
* ⚡ Add URL
* 🐛 Fix handler name
* ⚡ Use constant
* ♻️ 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.
* ⚡ Fixes for canvas pin data (#3587)
* ⚡ Fixes for canvas pin data
* 📘 Rename type
* 🧪 Fix unrelated Public API test
* 🔥 Remove logging
* feat: Updated pinData mixin to no longer include extra fields.
* ⚡ Output same pindata for every run
* 🎨 Fix cropping
* 🔥 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.
* ⚡ Fix pindata items per run
* 👕 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.
* 🔥 Remove conflict markers
* ✏️ Update i18n keys
* ⚡ Add JSON validation
* 🗃️ Add `pinData` column to `workflow_entity`
* 📘 Tighten type
* ⚡ Make `pinData` column nullable
* ⚡ Adjust workflow endpoints for pin data
* 📘 Improve types
* ✏️ Improve wording
* Inject pindata into items flow (#3420)
* ⚡ Inject pin data - Second approach
* 🔥 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.
* ⏪ Undo button change
* 🐛 Fix runNode call
Adjust per update in bdb84130d6
* 🧪 Fix workflow tests
* 🐛 More merge conflict fixes
* feat: Added pin/unpin button and store mutations.
* feat: Size check. Various design and ux improvements.
* ⚡ Add transformer
* ⚡ Hoist pin data
* ⚡ Adjust endpoints for hoisted pin data
* 📘 Expand interface
* 🐛 Fix stray array
* 👕 Fix build
* 🎨 Add color secondary tints
* ✨ Create `HeaderMessage` component
* ⚡ Adjust `InfoTip` component
* ✨ Add `HeaderMessage` to `RunData`
* 🐛 Fix console error
* 👕 Fix lint
* ⚡ Consolidate `HeaderMessage` and `Callout`
* ⏪ Undo `InfoTip` changes
* 🔥 Remove duplicate icons
* ⚡ Simplify template
* 🎨 Change cursor for action text
* 👕 Fix lint
* ⚡ Add URL
* 🐛 Fix handler name
* ⚡ Use constant
* ♻️ 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.
* ⚡ Fixes for canvas pin data (#3587)
* ⚡ Fixes for canvas pin data
* 📘 Rename type
* 🧪 Fix unrelated Public API test
* 🔥 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.
* ⚡ Output same pindata for every run
* 🎨 Fix cropping
* 🐛 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.
* ⚡ Implement telemetry
* ♻️ 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.
* 🧪 Fix data pinning workflow retrieval test
* 🔥 Remove unused imports
* 🔥 Remove leftover line
* ⚡ Skip pindata node issues on BE
* ⚡ Skip pindata node issues on FE
* ⚡ Hide `RunInfo` for pindata node
* ⚡ 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.
* 🐛 Fix runtime error with non-array
* fix: Loading pin data when opening execution.
* ⚡ Adjust stale data warning for pinned data
* ⚡ Skip auth in endpoint
* ⚡ Mark start node for pinned trigger
* ✏️ Comment on passthrough
* 🔥 Remove comment
* Final pindata metrics changes (#3673)
* 🐛 Fix `pinData` tracked as `0`
* ⚡ Add `is_pinned` to `nodesGraph`
* 📘 Extend `IWorkflowBase`
* ⚡ Handle `pinData` being `undefined`
* ⚡ Add `data_pinning_tooltip_presented`
* ♻️ Refactor to remove circular dependency
* fix: Added pin data handling when importing workflow. (#3698)
* 🔥 Remove helper from WorkflowExecute
* ⚡ Add logic for single pinned trigger
* 👕 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)
* ⚡ Disable edit button for disabled node
* ⚡ Ensure disabled pinned nodes are passthrough
* 🐛 Fix JSON key unfurling in edit mode
* ⚡ Improve implementation
* 🐛 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 <ivov.src@gmail.com>
* 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.
* 🎨 Add pindata styles
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
@@ -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[];
|
||||
|
||||
@@ -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<WorkflowEntity>(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<SharedWorkflow>(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<IExecutionPushResponse> => {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
134
packages/cli/src/api/workflows.api.ts
Normal file
134
packages/cli/src/api/workflows.api.ts
Normal file
@@ -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<WorkflowEntity>(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<SharedWorkflow>(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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -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();
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
const tablePrefix = config.getEnv('database.tablePrefix');
|
||||
|
||||
await queryRunner.query(`ALTER TABLE \`${tablePrefix}workflow_entity\` DROP COLUMN \`pinData\``);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
2
packages/cli/src/requests.d.ts
vendored
2
packages/cli/src/requests.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user