mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
feat(editor): Handle pin data edge cases and unify validation (no-changelog) (#6685)
Github issue / Community forum post (link here to close automatically):
This commit is contained in:
parent
27f37091c8
commit
721a36637c
2
.github/workflows/e2e-reusable.yml
vendored
2
.github/workflows/e2e-reusable.yml
vendored
|
@ -99,6 +99,8 @@ jobs:
|
||||||
runTests: false
|
runTests: false
|
||||||
install: false
|
install: false
|
||||||
build: pnpm build
|
build: pnpm build
|
||||||
|
env:
|
||||||
|
VUE_APP_MAX_PINNED_DATA_SIZE: 16384
|
||||||
|
|
||||||
- name: Cypress install
|
- name: Cypress install
|
||||||
run: pnpm cypress:install
|
run: pnpm cypress:install
|
||||||
|
|
|
@ -19,4 +19,9 @@ module.exports = defineConfig({
|
||||||
experimentalInteractiveRunEvents: true,
|
experimentalInteractiveRunEvents: true,
|
||||||
experimentalSessionAndOrigin: true,
|
experimentalSessionAndOrigin: true,
|
||||||
},
|
},
|
||||||
|
env: {
|
||||||
|
MAX_PINNED_DATA_SIZE: process.env.VUE_APP_MAX_PINNED_DATA_SIZE
|
||||||
|
? parseInt(process.env.VUE_APP_MAX_PINNED_DATA_SIZE, 10)
|
||||||
|
: 16 * 1024,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -62,13 +62,49 @@ describe('Data pinning', () => {
|
||||||
|
|
||||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||||
|
|
||||||
cy.reload();
|
|
||||||
workflowPage.actions.openNode('Schedule Trigger');
|
workflowPage.actions.openNode('Schedule Trigger');
|
||||||
|
|
||||||
ndv.getters.outputTableHeaders().first().should('include.text', 'test');
|
ndv.getters.outputTableHeaders().first().should('include.text', 'test');
|
||||||
ndv.getters.outputTbodyCell(1, 0).should('include.text', 1);
|
ndv.getters.outputTbodyCell(1, 0).should('include.text', 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Should be duplicating pin data when duplicating node', () => {
|
||||||
|
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger');
|
||||||
|
workflowPage.actions.addNodeToCanvas('Edit Fields', true, true);
|
||||||
|
ndv.getters.container().should('be.visible');
|
||||||
|
ndv.getters.pinDataButton().should('not.exist');
|
||||||
|
ndv.getters.editPinnedDataButton().should('be.visible');
|
||||||
|
|
||||||
|
ndv.actions.setPinnedData([{ test: 1 }]);
|
||||||
|
ndv.actions.close();
|
||||||
|
|
||||||
|
workflowPage.actions.duplicateNode(workflowPage.getters.canvasNodes().last());
|
||||||
|
|
||||||
|
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||||
|
|
||||||
|
workflowPage.actions.openNode('Edit Fields1');
|
||||||
|
|
||||||
|
ndv.getters.outputTableHeaders().first().should('include.text', 'test');
|
||||||
|
ndv.getters.outputTbodyCell(1, 0).should('include.text', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should show an error when maximum pin data size is exceeded', () => {
|
||||||
|
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger');
|
||||||
|
workflowPage.actions.addNodeToCanvas('Edit Fields', true, true);
|
||||||
|
ndv.getters.container().should('be.visible');
|
||||||
|
ndv.getters.pinDataButton().should('not.exist');
|
||||||
|
ndv.getters.editPinnedDataButton().should('be.visible');
|
||||||
|
|
||||||
|
ndv.actions.setPinnedData([
|
||||||
|
{
|
||||||
|
test: '1'.repeat(Cypress.env('MAX_PINNED_DATA_SIZE')),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
workflowPage.getters
|
||||||
|
.errorToast()
|
||||||
|
.should('contain', 'Workflow has reached the maximum allowed pinned data size');
|
||||||
|
});
|
||||||
|
|
||||||
it('Should be able to reference paired items in a node located before pinned data', () => {
|
it('Should be able to reference paired items in a node located before pinned data', () => {
|
||||||
workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
workflowPage.actions.addNodeToCanvas(HTTP_REQUEST_NODE_NAME, true, true);
|
workflowPage.actions.addNodeToCanvas(HTTP_REQUEST_NODE_NAME, true, true);
|
||||||
|
|
|
@ -104,7 +104,12 @@ export class NDV extends BasePage {
|
||||||
this.getters.pinnedDataEditor().click();
|
this.getters.pinnedDataEditor().click();
|
||||||
this.getters
|
this.getters
|
||||||
.pinnedDataEditor()
|
.pinnedDataEditor()
|
||||||
.type(`{selectall}{backspace}${JSON.stringify(data).replace(new RegExp('{', 'g'), '{{}')}`);
|
.type(
|
||||||
|
`{selectall}{backspace}${JSON.stringify(data).replace(new RegExp('{', 'g'), '{{}')}`,
|
||||||
|
{
|
||||||
|
delay: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
this.actions.savePinnedData();
|
this.actions.savePinnedData();
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { META_KEY } from '../constants';
|
||||||
import { BasePage } from './base';
|
import { BasePage } from './base';
|
||||||
import { getVisibleSelect } from '../utils';
|
import { getVisibleSelect } from '../utils';
|
||||||
import { NodeCreator } from './features/node-creator';
|
import { NodeCreator } from './features/node-creator';
|
||||||
|
import Chainable = Cypress.Chainable;
|
||||||
|
|
||||||
const nodeCreator = new NodeCreator();
|
const nodeCreator = new NodeCreator();
|
||||||
export class WorkflowPage extends BasePage {
|
export class WorkflowPage extends BasePage {
|
||||||
|
@ -46,8 +47,8 @@ export class WorkflowPage extends BasePage {
|
||||||
canvasNodePlusEndpointByName: (nodeName: string, index = 0) => {
|
canvasNodePlusEndpointByName: (nodeName: string, index = 0) => {
|
||||||
return cy.get(this.getters.getEndpointSelector('plus', nodeName, index));
|
return cy.get(this.getters.getEndpointSelector('plus', nodeName, index));
|
||||||
},
|
},
|
||||||
successToast: () => cy.get('.el-notification .el-notification--success').parent(),
|
successToast: () => cy.get('.el-notification:has(.el-notification--success)'),
|
||||||
errorToast: () => cy.get('.el-notification .el-notification--error'),
|
errorToast: () => cy.get('.el-notification:has(.el-notification--error)'),
|
||||||
activatorSwitch: () => cy.getByTestId('workflow-activate-switch'),
|
activatorSwitch: () => cy.getByTestId('workflow-activate-switch'),
|
||||||
workflowMenu: () => cy.getByTestId('workflow-menu'),
|
workflowMenu: () => cy.getByTestId('workflow-menu'),
|
||||||
firstStepButton: () => cy.getByTestId('canvas-add-button'),
|
firstStepButton: () => cy.getByTestId('canvas-add-button'),
|
||||||
|
@ -186,6 +187,9 @@ export class WorkflowPage extends BasePage {
|
||||||
openNode: (nodeTypeName: string) => {
|
openNode: (nodeTypeName: string) => {
|
||||||
this.getters.canvasNodeByName(nodeTypeName).first().dblclick();
|
this.getters.canvasNodeByName(nodeTypeName).first().dblclick();
|
||||||
},
|
},
|
||||||
|
duplicateNode: (node: Chainable<JQuery<HTMLElement>>) => {
|
||||||
|
node.find('[data-test-id="duplicate-node-button"]').click({ force: true });
|
||||||
|
},
|
||||||
openExpressionEditorModal: () => {
|
openExpressionEditorModal: () => {
|
||||||
cy.contains('Expression').invoke('show').click();
|
cy.contains('Expression').invoke('show').click();
|
||||||
cy.getByTestId('expander').invoke('show').click();
|
cy.getByTestId('expander').invoke('show').click();
|
||||||
|
|
|
@ -660,24 +660,12 @@ export default defineComponent({
|
||||||
if (shouldPinDataBeforeClosing === MODAL_CONFIRM) {
|
if (shouldPinDataBeforeClosing === MODAL_CONFIRM) {
|
||||||
const { value } = this.outputPanelEditMode;
|
const { value } = this.outputPanelEditMode;
|
||||||
|
|
||||||
if (!this.isValidPinDataSize(value)) {
|
|
||||||
dataPinningEventBus.emit('data-pinning-error', {
|
|
||||||
errorType: 'data-too-large',
|
|
||||||
source: 'on-ndv-close-modal',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.isValidPinDataJSON(value)) {
|
|
||||||
dataPinningEventBus.emit('data-pinning-error', {
|
|
||||||
errorType: 'invalid-json',
|
|
||||||
source: 'on-ndv-close-modal',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.activeNode) {
|
if (this.activeNode) {
|
||||||
this.workflowsStore.pinData({ node: this.activeNode, data: jsonParse(value) });
|
try {
|
||||||
|
this.setPinData(this.activeNode, jsonParse(value), 'on-ndv-close-modal');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,6 @@ import type { INodeUi } from '@/Interface';
|
||||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||||
import { workflowRun } from '@/mixins/workflowRun';
|
import { workflowRun } from '@/mixins/workflowRun';
|
||||||
import { pinData } from '@/mixins/pinData';
|
import { pinData } from '@/mixins/pinData';
|
||||||
import { dataPinningEventBus } from '@/event-bus';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
@ -228,9 +227,8 @@ export default defineComponent({
|
||||||
);
|
);
|
||||||
shouldUnpinAndExecute = confirmResult === MODAL_CONFIRM;
|
shouldUnpinAndExecute = confirmResult === MODAL_CONFIRM;
|
||||||
|
|
||||||
if (shouldUnpinAndExecute) {
|
if (shouldUnpinAndExecute && this.node) {
|
||||||
dataPinningEventBus.emit('data-unpinning', { source: 'unpin-and-execute-modal' });
|
this.unsetPinData(this.node, 'unpin-and-execute-modal');
|
||||||
this.workflowsStore.unpinData({ node: this.node });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -538,9 +538,10 @@ import { externalHooks } from '@/mixins/externalHooks';
|
||||||
import { genericHelpers } from '@/mixins/genericHelpers';
|
import { genericHelpers } from '@/mixins/genericHelpers';
|
||||||
import { nodeHelpers } from '@/mixins/nodeHelpers';
|
import { nodeHelpers } from '@/mixins/nodeHelpers';
|
||||||
import { pinData } from '@/mixins/pinData';
|
import { pinData } from '@/mixins/pinData';
|
||||||
|
import type { PinDataSource } from '@/mixins/pinData';
|
||||||
import CodeNodeEditor from '@/components/CodeNodeEditor/CodeNodeEditor.vue';
|
import CodeNodeEditor from '@/components/CodeNodeEditor/CodeNodeEditor.vue';
|
||||||
import { dataPinningEventBus } from '@/event-bus';
|
import { dataPinningEventBus } from '@/event-bus';
|
||||||
import { clearJsonKey, executionDataToJson, stringSizeInBytes, isEmpty } from '@/utils';
|
import { clearJsonKey, executionDataToJson, isEmpty } from '@/utils';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
@ -646,9 +647,6 @@ export default defineComponent({
|
||||||
this.init();
|
this.init();
|
||||||
|
|
||||||
if (!this.isPaneTypeInput) {
|
if (!this.isPaneTypeInput) {
|
||||||
dataPinningEventBus.on('data-pinning-error', this.onDataPinningError);
|
|
||||||
dataPinningEventBus.on('data-unpinning', this.onDataUnpinning);
|
|
||||||
|
|
||||||
this.showPinDataDiscoveryTooltip(this.jsonData);
|
this.showPinDataDiscoveryTooltip(this.jsonData);
|
||||||
}
|
}
|
||||||
this.ndvStore.setNDVBranchIndex({
|
this.ndvStore.setNDVBranchIndex({
|
||||||
|
@ -660,8 +658,6 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
this.hidePinDataDiscoveryTooltip();
|
this.hidePinDataDiscoveryTooltip();
|
||||||
dataPinningEventBus.off('data-pinning-error', this.onDataPinningError);
|
|
||||||
dataPinningEventBus.off('data-unpinning', this.onDataUnpinning);
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapStores(useNodeTypesStore, useNDVStore, useWorkflowsStore),
|
...mapStores(useNodeTypesStore, useNDVStore, useWorkflowsStore),
|
||||||
|
@ -1028,27 +1024,22 @@ export default defineComponent({
|
||||||
this.onExitEditMode({ type: 'cancel' });
|
this.onExitEditMode({ type: 'cancel' });
|
||||||
},
|
},
|
||||||
onClickSaveEdit() {
|
onClickSaveEdit() {
|
||||||
|
if (!this.node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { value } = this.editMode;
|
const { value } = this.editMode;
|
||||||
|
|
||||||
this.clearAllStickyNotifications();
|
this.clearAllStickyNotifications();
|
||||||
|
|
||||||
if (!this.isValidPinDataSize(value)) {
|
try {
|
||||||
this.onDataPinningError({ errorType: 'data-too-large', source: 'save-edit' });
|
this.setPinData(this.node, clearJsonKey(value) as INodeExecutionData[], 'save-edit');
|
||||||
return;
|
} catch (error) {
|
||||||
}
|
console.error(error);
|
||||||
|
|
||||||
if (!this.isValidPinDataJSON(value)) {
|
|
||||||
this.onDataPinningError({ errorType: 'invalid-json', source: 'save-edit' });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ndvStore.setOutputPanelEditModeEnabled(false);
|
this.ndvStore.setOutputPanelEditModeEnabled(false);
|
||||||
this.workflowsStore.pinData({
|
|
||||||
node: this.node,
|
|
||||||
data: clearJsonKey(value) as INodeExecutionData[],
|
|
||||||
});
|
|
||||||
|
|
||||||
this.onDataPinningSuccess({ source: 'save-edit' });
|
|
||||||
|
|
||||||
this.onExitEditMode({ type: 'save' });
|
this.onExitEditMode({ type: 'save' });
|
||||||
},
|
},
|
||||||
|
@ -1061,53 +1052,11 @@ export default defineComponent({
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDataUnpinning({
|
async onTogglePinData({ source }: { source: PinDataSource }) {
|
||||||
source,
|
if (!this.node) {
|
||||||
}: {
|
return;
|
||||||
source: 'banner-link' | 'pin-icon-click' | 'unpin-and-execute-modal';
|
}
|
||||||
}) {
|
|
||||||
this.$telemetry.track('User unpinned ndv data', {
|
|
||||||
node_type: this.activeNode?.type,
|
|
||||||
session_id: this.sessionId,
|
|
||||||
run_index: this.runIndex,
|
|
||||||
source,
|
|
||||||
data_size: stringSizeInBytes(this.pinData),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onDataPinningSuccess({ source }: { source: 'pin-icon-click' | 'save-edit' }) {
|
|
||||||
const telemetryPayload = {
|
|
||||||
pinning_source: source,
|
|
||||||
node_type: this.activeNode.type,
|
|
||||||
session_id: this.sessionId,
|
|
||||||
data_size: stringSizeInBytes(this.pinData),
|
|
||||||
view: this.displayMode,
|
|
||||||
run_index: this.runIndex,
|
|
||||||
};
|
|
||||||
void this.$externalHooks().run('runData.onDataPinningSuccess', telemetryPayload);
|
|
||||||
this.$telemetry.track('Ndv data pinning success', telemetryPayload);
|
|
||||||
},
|
|
||||||
onDataPinningError({
|
|
||||||
errorType,
|
|
||||||
source,
|
|
||||||
}: {
|
|
||||||
errorType: 'data-too-large' | 'invalid-json';
|
|
||||||
source: 'on-ndv-close-modal' | 'pin-icon-click' | 'save-edit';
|
|
||||||
}) {
|
|
||||||
this.$telemetry.track('Ndv data pinning failure', {
|
|
||||||
pinning_source: source,
|
|
||||||
node_type: this.activeNode.type,
|
|
||||||
session_id: this.sessionId,
|
|
||||||
data_size: stringSizeInBytes(this.pinData),
|
|
||||||
view: this.displayMode,
|
|
||||||
run_index: this.runIndex,
|
|
||||||
error_type: errorType,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async onTogglePinData({
|
|
||||||
source,
|
|
||||||
}: {
|
|
||||||
source: 'banner-link' | 'pin-icon-click' | 'unpin-and-execute-modal';
|
|
||||||
}) {
|
|
||||||
if (source === 'pin-icon-click') {
|
if (source === 'pin-icon-click') {
|
||||||
const telemetryPayload = {
|
const telemetryPayload = {
|
||||||
node_type: this.activeNode.type,
|
node_type: this.activeNode.type,
|
||||||
|
@ -1123,20 +1072,17 @@ export default defineComponent({
|
||||||
this.updateNodeParameterIssues(this.node);
|
this.updateNodeParameterIssues(this.node);
|
||||||
|
|
||||||
if (this.hasPinData) {
|
if (this.hasPinData) {
|
||||||
this.onDataUnpinning({ source });
|
this.unsetPinData(this.node, source);
|
||||||
this.workflowsStore.unpinData({ node: this.node });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.isValidPinDataSize(this.rawInputData)) {
|
try {
|
||||||
this.onDataPinningError({ errorType: 'data-too-large', source: 'pin-icon-click' });
|
this.setPinData(this.node, this.rawInputData, 'pin-icon-click');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.onDataPinningSuccess({ source: 'pin-icon-click' });
|
|
||||||
|
|
||||||
this.workflowsStore.pinData({ node: this.node, data: this.rawInputData });
|
|
||||||
|
|
||||||
if (this.maxRunIndex > 0) {
|
if (this.maxRunIndex > 0) {
|
||||||
this.showToast({
|
this.showToast({
|
||||||
title: this.$locale.baseText('ndv.pinData.pin.multipleRuns.title', {
|
title: this.$locale.baseText('ndv.pinData.pin.multipleRuns.title', {
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
import type { NodeCreatorOpenSource } from './Interface';
|
import type { NodeCreatorOpenSource } from './Interface';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
|
||||||
export const MAX_WORKFLOW_SIZE = 16777216; // Workflow size limit in bytes
|
export const MAX_WORKFLOW_SIZE = 1024 * 1024 * 16; // Workflow size limit in bytes
|
||||||
export const MAX_WORKFLOW_PINNED_DATA_SIZE = 12582912; // Workflow pinned data size limit in bytes
|
export const MAX_EXPECTED_REQUEST_SIZE = 2048; // Expected maximum workflow request metadata (i.e. headers) size in bytes
|
||||||
export const MAX_DISPLAY_DATA_SIZE = 204800;
|
export const MAX_PINNED_DATA_SIZE = import.meta.env.VUE_APP_MAX_PINNED_DATA_SIZE
|
||||||
|
? parseInt(import.meta.env.VUE_APP_MAX_PINNED_DATA_SIZE, 10)
|
||||||
|
: 1024 * 1024 * 12; // Workflow pinned data size limit in bytes
|
||||||
|
export const MAX_DISPLAY_DATA_SIZE = 1024 * 200;
|
||||||
export const MAX_DISPLAY_ITEMS_AUTO_ALL = 250;
|
export const MAX_DISPLAY_ITEMS_AUTO_ALL = 250;
|
||||||
|
|
||||||
export const PLACEHOLDER_FILLED_AT_EXECUTION_TIME = '[filled at execution time]';
|
export const PLACEHOLDER_FILLED_AT_EXECUTION_TIME = '[filled at execution time]';
|
||||||
|
|
|
@ -1,17 +1,27 @@
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import type { INodeUi } from '@/Interface';
|
import type { INodeUi } from '@/Interface';
|
||||||
import type { INodeTypeDescription, IPinData } from 'n8n-workflow';
|
import type { IPinData, INodeExecutionData } from 'n8n-workflow';
|
||||||
import { stringSizeInBytes } from '@/utils';
|
import { stringSizeInBytes } from '@/utils';
|
||||||
import { MAX_WORKFLOW_PINNED_DATA_SIZE, PIN_DATA_NODE_TYPES_DENYLIST } from '@/constants';
|
import {
|
||||||
|
MAX_EXPECTED_REQUEST_SIZE,
|
||||||
|
MAX_PINNED_DATA_SIZE,
|
||||||
|
MAX_WORKFLOW_SIZE,
|
||||||
|
PIN_DATA_NODE_TYPES_DENYLIST,
|
||||||
|
} from '@/constants';
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { useToast } from '@/composables';
|
import { useToast } from '@/composables';
|
||||||
|
import { jsonParse, jsonStringify } from 'n8n-workflow';
|
||||||
|
|
||||||
export interface IPinDataContext {
|
export type PinDataSource =
|
||||||
node: INodeUi;
|
| 'pin-icon-click'
|
||||||
nodeType: INodeTypeDescription;
|
| 'save-edit'
|
||||||
$showError(error: Error, title: string): void;
|
| 'on-ndv-close-modal'
|
||||||
}
|
| 'duplicate-node'
|
||||||
|
| 'add-nodes';
|
||||||
|
|
||||||
|
export type UnpinDataSource = 'unpin-and-execute-modal';
|
||||||
|
|
||||||
export const pinData = defineComponent({
|
export const pinData = defineComponent({
|
||||||
setup() {
|
setup() {
|
||||||
|
@ -20,7 +30,7 @@ export const pinData = defineComponent({
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapStores(useWorkflowsStore),
|
...mapStores(useWorkflowsStore, useNDVStore),
|
||||||
pinData(): IPinData[string] | undefined {
|
pinData(): IPinData[string] | undefined {
|
||||||
return this.node ? this.workflowsStore.pinDataByNodeName(this.node.name) : undefined;
|
return this.node ? this.workflowsStore.pinDataByNodeName(this.node.name) : undefined;
|
||||||
},
|
},
|
||||||
|
@ -83,13 +93,16 @@ export const pinData = defineComponent({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isValidPinDataSize(data: string | object): boolean {
|
isValidPinDataSize(data: string | object, activeNodeName: string): boolean {
|
||||||
if (typeof data === 'object') data = JSON.stringify(data);
|
if (typeof data === 'object') data = JSON.stringify(data);
|
||||||
|
|
||||||
if (
|
const { pinData: currentPinData, ...workflow } = this.workflowsStore.getCurrentWorkflow();
|
||||||
this.workflowsStore.pinDataSize + stringSizeInBytes(data) >
|
const workflowJson = jsonStringify(workflow, { replaceCircularRefs: true });
|
||||||
MAX_WORKFLOW_PINNED_DATA_SIZE
|
|
||||||
) {
|
const newPinData = { ...currentPinData, [activeNodeName]: data };
|
||||||
|
const newPinDataSize = this.workflowsStore.getPinDataSize(newPinData);
|
||||||
|
|
||||||
|
if (newPinDataSize > MAX_PINNED_DATA_SIZE) {
|
||||||
this.showError(
|
this.showError(
|
||||||
new Error(this.$locale.baseText('ndv.pinData.error.tooLarge.description')),
|
new Error(this.$locale.baseText('ndv.pinData.error.tooLarge.description')),
|
||||||
this.$locale.baseText('ndv.pinData.error.tooLarge.title'),
|
this.$locale.baseText('ndv.pinData.error.tooLarge.title'),
|
||||||
|
@ -98,7 +111,83 @@ export const pinData = defineComponent({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
stringSizeInBytes(workflowJson) + newPinDataSize >
|
||||||
|
MAX_WORKFLOW_SIZE - MAX_EXPECTED_REQUEST_SIZE
|
||||||
|
) {
|
||||||
|
this.showError(
|
||||||
|
new Error(this.$locale.baseText('ndv.pinData.error.tooLargeWorkflow.description')),
|
||||||
|
this.$locale.baseText('ndv.pinData.error.tooLargeWorkflow.title'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
setPinData(node: INodeUi, data: string | INodeExecutionData[], source: PinDataSource): boolean {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
if (!this.isValidPinDataJSON(data)) {
|
||||||
|
this.onDataPinningError({ errorType: 'invalid-json', source });
|
||||||
|
throw new Error('Invalid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
data = jsonParse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isValidPinDataSize(data, node.name)) {
|
||||||
|
this.onDataPinningError({ errorType: 'data-too-large', source });
|
||||||
|
throw new Error('Data too large');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onDataPinningSuccess({ source });
|
||||||
|
this.workflowsStore.pinData({ node, data: data as INodeExecutionData[] });
|
||||||
|
},
|
||||||
|
unsetPinData(node: INodeUi, source: UnpinDataSource): void {
|
||||||
|
this.onDataUnpinning({ source });
|
||||||
|
this.workflowsStore.unpinData({ node });
|
||||||
|
},
|
||||||
|
onDataPinningSuccess({ source }: { source: PinDataSource }) {
|
||||||
|
const telemetryPayload = {
|
||||||
|
pinning_source: source,
|
||||||
|
node_type: this.activeNode?.type,
|
||||||
|
session_id: this.sessionId,
|
||||||
|
data_size: stringSizeInBytes(this.pinData),
|
||||||
|
view: this.displayMode,
|
||||||
|
run_index: this.runIndex,
|
||||||
|
};
|
||||||
|
void this.$externalHooks().run('runData.onDataPinningSuccess', telemetryPayload);
|
||||||
|
this.$telemetry.track('Ndv data pinning success', telemetryPayload);
|
||||||
|
},
|
||||||
|
onDataPinningError({
|
||||||
|
errorType,
|
||||||
|
source,
|
||||||
|
}: {
|
||||||
|
errorType: 'data-too-large' | 'invalid-json';
|
||||||
|
source: PinDataSource;
|
||||||
|
}) {
|
||||||
|
this.$telemetry.track('Ndv data pinning failure', {
|
||||||
|
pinning_source: source,
|
||||||
|
node_type: this.activeNode?.type,
|
||||||
|
session_id: this.sessionId,
|
||||||
|
data_size: stringSizeInBytes(this.pinData),
|
||||||
|
view: this.displayMode,
|
||||||
|
run_index: this.runIndex,
|
||||||
|
error_type: errorType,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onDataUnpinning({
|
||||||
|
source,
|
||||||
|
}: {
|
||||||
|
source: 'banner-link' | 'pin-icon-click' | 'unpin-and-execute-modal';
|
||||||
|
}) {
|
||||||
|
this.$telemetry.track('User unpinned ndv data', {
|
||||||
|
node_type: this.activeNode?.type,
|
||||||
|
session_id: this.sessionId,
|
||||||
|
run_index: this.runIndex,
|
||||||
|
source,
|
||||||
|
data_size: stringSizeInBytes(this.pinData),
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -795,8 +795,10 @@
|
||||||
"ndv.pinData.beforeClosing.title": "Save output changes before closing?",
|
"ndv.pinData.beforeClosing.title": "Save output changes before closing?",
|
||||||
"ndv.pinData.beforeClosing.cancel": "Discard",
|
"ndv.pinData.beforeClosing.cancel": "Discard",
|
||||||
"ndv.pinData.beforeClosing.confirm": "Save",
|
"ndv.pinData.beforeClosing.confirm": "Save",
|
||||||
"ndv.pinData.error.tooLarge.title": "Output data is too large to pin",
|
"ndv.pinData.error.tooLarge.title": "Pinned data too big",
|
||||||
"ndv.pinData.error.tooLarge.description": "You can pin at most 12MB of output per workflow.",
|
"ndv.pinData.error.tooLarge.description": "Workflow has reached the maximum allowed pinned data size",
|
||||||
|
"ndv.pinData.error.tooLargeWorkflow.title": "Pinned data too big",
|
||||||
|
"ndv.pinData.error.tooLargeWorkflow.description": "Workflow has reached the maximum allowed size",
|
||||||
"noTagsView.readyToOrganizeYourWorkflows": "Ready to organize your workflows?",
|
"noTagsView.readyToOrganizeYourWorkflows": "Ready to organize your workflows?",
|
||||||
"noTagsView.withWorkflowTagsYouReFree": "With workflow tags, you're free to create the perfect tagging system for your flows",
|
"noTagsView.withWorkflowTagsYouReFree": "With workflow tags, you're free to create the perfect tagging system for your flows",
|
||||||
"node.thisIsATriggerNode": "This is a Trigger node. <a target=\"_blank\" href=\"https://docs.n8n.io/workflows/components/nodes/\">Learn more</a>",
|
"node.thisIsATriggerNode": "This is a Trigger node. <a target=\"_blank\" href=\"https://docs.n8n.io/workflows/components/nodes/\">Learn more</a>",
|
||||||
|
|
1
packages/editor-ui/src/shims.d.ts
vendored
1
packages/editor-ui/src/shims.d.ts
vendored
|
@ -13,6 +13,7 @@ declare global {
|
||||||
PROD: boolean;
|
PROD: boolean;
|
||||||
NODE_ENV: 'development' | 'production';
|
NODE_ENV: 'development' | 'production';
|
||||||
VUE_APP_URL_BASE_API: string;
|
VUE_APP_URL_BASE_API: string;
|
||||||
|
VUE_APP_MAX_PINNED_DATA_SIZE: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -61,7 +61,7 @@ import type {
|
||||||
import { deepCopy, NodeHelpers, Workflow } from 'n8n-workflow';
|
import { deepCopy, NodeHelpers, Workflow } from 'n8n-workflow';
|
||||||
import { findLast } from 'lodash-es';
|
import { findLast } from 'lodash-es';
|
||||||
|
|
||||||
import { useRootStore } from './n8nRoot.store';
|
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||||
import {
|
import {
|
||||||
getActiveWorkflows,
|
getActiveWorkflows,
|
||||||
getCurrentExecutions,
|
getCurrentExecutions,
|
||||||
|
@ -71,7 +71,7 @@ import {
|
||||||
getWorkflow,
|
getWorkflow,
|
||||||
getWorkflows,
|
getWorkflows,
|
||||||
} from '@/api/workflows';
|
} from '@/api/workflows';
|
||||||
import { useUIStore } from './ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { dataPinningEventBus } from '@/event-bus';
|
import { dataPinningEventBus } from '@/event-bus';
|
||||||
import {
|
import {
|
||||||
isJsonKeyObject,
|
isJsonKeyObject,
|
||||||
|
@ -82,8 +82,8 @@ import {
|
||||||
makeRestApiRequest,
|
makeRestApiRequest,
|
||||||
unflattenExecutionData,
|
unflattenExecutionData,
|
||||||
} from '@/utils';
|
} from '@/utils';
|
||||||
import { useNDVStore } from './ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { useNodeTypesStore } from './nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
|
@ -237,17 +237,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
|
||||||
getPinData(): IPinData | undefined {
|
getPinData(): IPinData | undefined {
|
||||||
return this.workflow.pinData;
|
return this.workflow.pinData;
|
||||||
},
|
},
|
||||||
pinDataSize(): number {
|
|
||||||
const ndvStore = useNDVStore();
|
|
||||||
const activeNode = ndvStore.activeNodeName;
|
|
||||||
return this.workflow.nodes.reduce((acc, node) => {
|
|
||||||
if (typeof node.pinData !== 'undefined' && node.name !== activeNode) {
|
|
||||||
acc += stringSizeInBytes(node.pinData);
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
shouldReplaceInputDataWithPinData(): boolean {
|
shouldReplaceInputDataWithPinData(): boolean {
|
||||||
return !this.activeWorkflowExecution || this.activeWorkflowExecution?.mode === 'manual';
|
return !this.activeWorkflowExecution || this.activeWorkflowExecution?.mode === 'manual';
|
||||||
},
|
},
|
||||||
|
@ -283,6 +272,11 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
getPinDataSize(pinData: Record<string, string | INodeExecutionData[]> = {}): number {
|
||||||
|
return Object.values(pinData).reduce<number>((acc, value) => {
|
||||||
|
return acc + stringSizeInBytes(value);
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
getNodeTypes(): INodeTypes {
|
getNodeTypes(): INodeTypes {
|
||||||
const nodeTypes: INodeTypes = {
|
const nodeTypes: INodeTypes = {
|
||||||
nodeTypes: {},
|
nodeTypes: {},
|
||||||
|
|
|
@ -241,6 +241,7 @@ import { useUniqueNodeName } from '@/composables/useUniqueNodeName';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||||
import { workflowRun } from '@/mixins/workflowRun';
|
import { workflowRun } from '@/mixins/workflowRun';
|
||||||
|
import { pinData } from '@/mixins/pinData';
|
||||||
|
|
||||||
import NodeDetailsView from '@/components/NodeDetailsView.vue';
|
import NodeDetailsView from '@/components/NodeDetailsView.vue';
|
||||||
import Node from '@/components/Node.vue';
|
import Node from '@/components/Node.vue';
|
||||||
|
@ -367,6 +368,7 @@ export default defineComponent({
|
||||||
workflowHelpers,
|
workflowHelpers,
|
||||||
workflowRun,
|
workflowRun,
|
||||||
debounceHelper,
|
debounceHelper,
|
||||||
|
pinData,
|
||||||
],
|
],
|
||||||
components: {
|
components: {
|
||||||
NodeDetailsView,
|
NodeDetailsView,
|
||||||
|
@ -1719,10 +1721,6 @@ export default defineComponent({
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (workflowData.pinData) {
|
|
||||||
this.workflowsStore.setWorkflowPinData(workflowData.pinData);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagsEnabled = this.settingsStore.areTagsEnabled;
|
const tagsEnabled = this.settingsStore.areTagsEnabled;
|
||||||
if (importTags && tagsEnabled && Array.isArray(workflowData.tags)) {
|
if (importTags && tagsEnabled && Array.isArray(workflowData.tags)) {
|
||||||
const allTags = await this.tagsStore.fetchAll();
|
const allTags = await this.tagsStore.fetchAll();
|
||||||
|
@ -3234,12 +3232,13 @@ export default defineComponent({
|
||||||
|
|
||||||
await this.addNodes([newNodeData], [], true);
|
await this.addNodes([newNodeData], [], true);
|
||||||
|
|
||||||
const pinData = this.workflowsStore.pinDataByNodeName(nodeName);
|
const pinDataForNode = this.workflowsStore.pinDataByNodeName(nodeName);
|
||||||
if (pinData) {
|
if (pinDataForNode?.length) {
|
||||||
this.workflowsStore.pinData({
|
try {
|
||||||
node: newNodeData,
|
this.setPinData(newNodeData, pinDataForNode, 'duplicate-node');
|
||||||
data: pinData,
|
} catch (error) {
|
||||||
});
|
console.error(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.uiStore.stateIsDirty = true;
|
this.uiStore.stateIsDirty = true;
|
||||||
|
@ -3963,6 +3962,29 @@ export default defineComponent({
|
||||||
tempWorkflow.renameNode(oldName, nodeNameTable[oldName]);
|
tempWorkflow.renameNode(oldName, nodeNameTable[oldName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.pinData) {
|
||||||
|
let pinDataSuccess = true;
|
||||||
|
for (const nodeName of Object.keys(data.pinData)) {
|
||||||
|
// Pin data limit reached
|
||||||
|
if (!pinDataSuccess) {
|
||||||
|
this.showError(
|
||||||
|
new Error(this.$locale.baseText('ndv.pinData.error.tooLarge.description')),
|
||||||
|
this.$locale.baseText('ndv.pinData.error.tooLarge.title'),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = tempWorkflow.nodes[nodeNameTable[nodeName]];
|
||||||
|
try {
|
||||||
|
this.setPinData(node, data.pinData![nodeName], 'add-nodes');
|
||||||
|
pinDataSuccess = true;
|
||||||
|
} catch (error) {
|
||||||
|
pinDataSuccess = false;
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add the nodes with the changed node names, expressions and connections
|
// Add the nodes with the changed node names, expressions and connections
|
||||||
this.historyStore.startRecordingUndo();
|
this.historyStore.startRecordingUndo();
|
||||||
await this.addNodes(
|
await this.addNodes(
|
||||||
|
|
|
@ -13,6 +13,7 @@ function runTests(options) {
|
||||||
process.env.N8N_USER_FOLDER = userFolder;
|
process.env.N8N_USER_FOLDER = userFolder;
|
||||||
process.env.E2E_TESTS = 'true';
|
process.env.E2E_TESTS = 'true';
|
||||||
process.env.NODE_OPTIONS = '--dns-result-order=ipv4first';
|
process.env.NODE_OPTIONS = '--dns-result-order=ipv4first';
|
||||||
|
process.env.VUE_APP_MAX_PINNED_DATA_SIZE = `${16 * 1024}`;
|
||||||
|
|
||||||
if (options.customEnv) {
|
if (options.customEnv) {
|
||||||
Object.keys(options.customEnv).forEach((key) => {
|
Object.keys(options.customEnv).forEach((key) => {
|
||||||
|
|
Loading…
Reference in a new issue