Skip to content

Commit 32df171

Browse files
authored
fix(editor): Allow pinning of AI root nodes (#9060)
Signed-off-by: Oleg Ivaniv <[email protected]>
1 parent caea27d commit 32df171

File tree

7 files changed

+127
-121
lines changed

7 files changed

+127
-121
lines changed

cypress/e2e/13-pinning.cy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ describe('Data pinning', () => {
134134
ndv.getters.pinDataButton().should('not.exist');
135135
ndv.getters.editPinnedDataButton().should('be.visible');
136136

137-
ndv.actions.setPinnedData([
137+
ndv.actions.pastePinnedData([
138138
{
139139
test: '1'.repeat(Cypress.env('MAX_PINNED_DATA_SIZE')),
140140
},

cypress/e2e/14-mapping.cy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ describe('Data mapping', () => {
206206
workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
207207
workflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
208208
workflowPage.actions.openNode(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
209-
ndv.actions.setPinnedData([
209+
ndv.actions.pastePinnedData([
210210
{
211211
input: [
212212
{

cypress/e2e/24-ndv-paired-item.cy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ describe('NDV', () => {
324324
];
325325
/* prettier-ignore */
326326
workflowPage.actions.openNode('Get thread details1');
327-
ndv.actions.setPinnedData(PINNED_DATA);
327+
ndv.actions.pastePinnedData(PINNED_DATA);
328328
ndv.actions.close();
329329

330330
workflowPage.actions.executeWorkflow();

cypress/pages/ndv.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,17 @@ export class NDV extends BasePage {
155155

156156
this.actions.savePinnedData();
157157
},
158+
pastePinnedData: (data: object) => {
159+
this.getters.editPinnedDataButton().click();
160+
161+
this.getters.pinnedDataEditor().click();
162+
this.getters
163+
.pinnedDataEditor()
164+
.type('{selectall}{backspace}', { delay: 0 })
165+
.paste(JSON.stringify(data));
166+
167+
this.actions.savePinnedData();
168+
},
158169
clearParameterInput: (parameterName: string) => {
159170
this.getters.parameterInput(parameterName).type(`{selectall}{backspace}`);
160171
},

packages/editor-ui/src/components/RunData.vue

Lines changed: 84 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,8 @@
217217
<div :class="[$style.editModeBody, 'ignore-key-press']">
218218
<JsonEditor
219219
:model-value="editMode.value"
220-
@update:model-value="ndvStore.setOutputPanelEditModeValue($event)"
221220
:fill-parent="true"
221+
@update:model-value="ndvStore.setOutputPanelEditModeValue($event)"
222222
/>
223223
</div>
224224
<div :class="$style.editModeFooter">
@@ -725,45 +725,6 @@ export default defineComponent({
725725
search: '',
726726
};
727727
},
728-
mounted() {
729-
this.init();
730-
731-
if (!this.isPaneTypeInput) {
732-
this.showPinDataDiscoveryTooltip(this.jsonData);
733-
}
734-
this.ndvStore.setNDVBranchIndex({
735-
pane: this.paneType as 'input' | 'output',
736-
branchIndex: this.currentOutputIndex,
737-
});
738-
739-
if (this.paneType === 'output') {
740-
this.setDisplayMode();
741-
this.activatePane();
742-
}
743-
744-
if (this.hasRunError) {
745-
const error = this.workflowRunData?.[this.node.name]?.[this.runIndex]?.error;
746-
const errorsToTrack = ['unknown error'];
747-
748-
if (error && errorsToTrack.some((e) => error.message.toLowerCase().includes(e))) {
749-
this.$telemetry.track(
750-
`User encountered an error: "${error.message}"`,
751-
{
752-
node: this.node.type,
753-
errorMessage: error.message,
754-
nodeVersion: this.node.typeVersion,
755-
n8nVersion: this.rootStore.versionCli,
756-
},
757-
{
758-
withPostHog: true,
759-
},
760-
);
761-
}
762-
}
763-
},
764-
beforeUnmount() {
765-
this.hidePinDataDiscoveryTooltip();
766-
},
767728
computed: {
768729
...mapStores(
769730
useNodeTypesStore,
@@ -803,21 +764,14 @@ export default defineComponent({
803764
return this.nodeTypesStore.isTriggerNode(this.node.type);
804765
},
805766
canPinData(): boolean {
806-
// Only "main" inputs can pin data
807-
808767
if (this.node === null) {
809768
return false;
810769
}
811770
812-
const workflow = this.workflowsStore.getCurrentWorkflow();
813-
const workflowNode = workflow.getNode(this.node.name);
814-
const inputs = NodeHelpers.getNodeInputs(workflow, workflowNode!, this.nodeType!);
815-
const inputNames = NodeHelpers.getConnectionTypes(inputs);
816-
817-
const nonMainInputs = !!inputNames.find((inputName) => inputName !== NodeConnectionType.Main);
771+
const canPinNode = usePinnedData(this.node).canPinNode(false);
818772
819773
return (
820-
!nonMainInputs &&
774+
canPinNode &&
821775
!this.isPaneTypeInput &&
822776
this.pinnedData.isValidNodeType.value &&
823777
!(this.binaryData && this.binaryData.length > 0)
@@ -1035,6 +989,87 @@ export default defineComponent({
1035989
return this.hasNodeRun && !this.inputData.length && this.search;
1036990
},
1037991
},
992+
watch: {
993+
node(newNode: INodeUi, prevNode: INodeUi) {
994+
if (newNode.id === prevNode.id) return;
995+
this.init();
996+
},
997+
hasNodeRun() {
998+
if (this.paneType === 'output') this.setDisplayMode();
999+
},
1000+
inputDataPage: {
1001+
handler(data: INodeExecutionData[]) {
1002+
if (this.paneType && data) {
1003+
this.ndvStore.setNDVPanelDataIsEmpty({
1004+
panel: this.paneType as 'input' | 'output',
1005+
isEmpty: data.every((item) => isEmpty(item.json)),
1006+
});
1007+
}
1008+
},
1009+
immediate: true,
1010+
deep: true,
1011+
},
1012+
jsonData(data: IDataObject[], prevData: IDataObject[]) {
1013+
if (isEqual(data, prevData)) return;
1014+
this.refreshDataSize();
1015+
this.showPinDataDiscoveryTooltip(data);
1016+
},
1017+
binaryData(newData: IBinaryKeyData[], prevData: IBinaryKeyData[]) {
1018+
if (newData.length && !prevData.length && this.displayMode !== 'binary') {
1019+
this.switchToBinary();
1020+
} else if (!newData.length && this.displayMode === 'binary') {
1021+
this.onDisplayModeChange('table');
1022+
}
1023+
},
1024+
currentOutputIndex(branchIndex: number) {
1025+
this.ndvStore.setNDVBranchIndex({
1026+
pane: this.paneType as 'input' | 'output',
1027+
branchIndex,
1028+
});
1029+
},
1030+
search(newSearch: string) {
1031+
this.$emit('search', newSearch);
1032+
},
1033+
},
1034+
mounted() {
1035+
this.init();
1036+
1037+
if (!this.isPaneTypeInput) {
1038+
this.showPinDataDiscoveryTooltip(this.jsonData);
1039+
}
1040+
this.ndvStore.setNDVBranchIndex({
1041+
pane: this.paneType as 'input' | 'output',
1042+
branchIndex: this.currentOutputIndex,
1043+
});
1044+
1045+
if (this.paneType === 'output') {
1046+
this.setDisplayMode();
1047+
this.activatePane();
1048+
}
1049+
1050+
if (this.hasRunError) {
1051+
const error = this.workflowRunData?.[this.node.name]?.[this.runIndex]?.error;
1052+
const errorsToTrack = ['unknown error'];
1053+
1054+
if (error && errorsToTrack.some((e) => error.message.toLowerCase().includes(e))) {
1055+
this.$telemetry.track(
1056+
`User encountered an error: "${error.message}"`,
1057+
{
1058+
node: this.node.type,
1059+
errorMessage: error.message,
1060+
nodeVersion: this.node.typeVersion,
1061+
n8nVersion: this.rootStore.versionCli,
1062+
},
1063+
{
1064+
withPostHog: true,
1065+
},
1066+
);
1067+
}
1068+
}
1069+
},
1070+
beforeUnmount() {
1071+
this.hidePinDataDiscoveryTooltip();
1072+
},
10381073
methods: {
10391074
getResolvedNodeOutputs() {
10401075
if (this.node && this.nodeType) {
@@ -1500,48 +1535,6 @@ export default defineComponent({
15001535
document.dispatchEvent(new KeyboardEvent('keyup', { key: '/' }));
15011536
},
15021537
},
1503-
watch: {
1504-
node(newNode: INodeUi, prevNode: INodeUi) {
1505-
if (newNode.id === prevNode.id) return;
1506-
this.init();
1507-
},
1508-
hasNodeRun() {
1509-
if (this.paneType === 'output') this.setDisplayMode();
1510-
},
1511-
inputDataPage: {
1512-
handler(data: INodeExecutionData[]) {
1513-
if (this.paneType && data) {
1514-
this.ndvStore.setNDVPanelDataIsEmpty({
1515-
panel: this.paneType as 'input' | 'output',
1516-
isEmpty: data.every((item) => isEmpty(item.json)),
1517-
});
1518-
}
1519-
},
1520-
immediate: true,
1521-
deep: true,
1522-
},
1523-
jsonData(data: IDataObject[], prevData: IDataObject[]) {
1524-
if (isEqual(data, prevData)) return;
1525-
this.refreshDataSize();
1526-
this.showPinDataDiscoveryTooltip(data);
1527-
},
1528-
binaryData(newData: IBinaryKeyData[], prevData: IBinaryKeyData[]) {
1529-
if (newData.length && !prevData.length && this.displayMode !== 'binary') {
1530-
this.switchToBinary();
1531-
} else if (!newData.length && this.displayMode === 'binary') {
1532-
this.onDisplayModeChange('table');
1533-
}
1534-
},
1535-
currentOutputIndex(branchIndex: number) {
1536-
this.ndvStore.setNDVBranchIndex({
1537-
pane: this.paneType as 'input' | 'output',
1538-
branchIndex,
1539-
});
1540-
},
1541-
search(newSearch: string) {
1542-
this.$emit('search', newSearch);
1543-
},
1544-
},
15451538
});
15461539
</script>
15471540

packages/editor-ui/src/composables/useContextMenu.ts

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,15 @@
11
import type { XYPosition } from '@/Interface';
2-
import {
3-
NOT_DUPLICATABE_NODE_TYPES,
4-
PIN_DATA_NODE_TYPES_DENYLIST,
5-
STICKY_NODE_TYPE,
6-
} from '@/constants';
2+
import { NOT_DUPLICATABE_NODE_TYPES, STICKY_NODE_TYPE } from '@/constants';
73
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
84
import { useSourceControlStore } from '@/stores/sourceControl.store';
95
import { useUIStore } from '@/stores/ui.store';
106
import { useWorkflowsStore } from '@/stores/workflows.store';
117
import type { IActionDropdownItem } from 'n8n-design-system/src/components/N8nActionDropdown/ActionDropdown.vue';
12-
import { NodeHelpers, NodeConnectionType } from 'n8n-workflow';
138
import type { INode, INodeTypeDescription } from 'n8n-workflow';
149
import { computed, ref, watch } from 'vue';
1510
import { getMousePosition } from '../utils/nodeViewUtils';
1611
import { useI18n } from './useI18n';
17-
import { useDataSchema } from './useDataSchema';
12+
import { usePinnedData } from './usePinnedData';
1813

1914
export type ContextMenuTarget =
2015
| { source: 'canvas' }
@@ -47,7 +42,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
4742
const nodeTypesStore = useNodeTypesStore();
4843
const workflowsStore = useWorkflowsStore();
4944
const sourceControlStore = useSourceControlStore();
50-
const { getInputDataWithPinned } = useDataSchema();
45+
5146
const i18n = useI18n();
5247

5348
const isReadOnly = computed(
@@ -83,13 +78,6 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
8378
return canAddNodeOfType(nodeType);
8479
};
8580

86-
const canPinNode = (node: INode): boolean => {
87-
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
88-
const dataToPin = getInputDataWithPinned(node);
89-
if (!nodeType || dataToPin.length === 0) return false;
90-
return nodeType.outputs.length === 1 && !PIN_DATA_NODE_TYPES_DENYLIST.includes(node.type);
91-
};
92-
9381
const hasPinData = (node: INode): boolean => {
9482
return !!workflowsStore.pinDataByNodeName(node.name);
9583
};
@@ -159,16 +147,6 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
159147
...selectionActions,
160148
];
161149
} else {
162-
const nonMainInputs = (node: INode) => {
163-
const workflow = workflowsStore.getCurrentWorkflow();
164-
const workflowNode = workflow.getNode(node.name);
165-
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
166-
const inputs = NodeHelpers.getNodeInputs(workflow, workflowNode!, nodeType!);
167-
const inputNames = NodeHelpers.getConnectionTypes(inputs);
168-
169-
return !!inputNames.find((inputName) => inputName !== NodeConnectionType.Main);
170-
};
171-
172150
const menuActions: IActionDropdownItem[] = [
173151
!onlyStickies && {
174152
id: 'toggle_activation',
@@ -184,7 +162,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
184162
? i18n.baseText('contextMenu.unpin', i18nOptions)
185163
: i18n.baseText('contextMenu.pin', i18nOptions),
186164
shortcut: { keys: ['p'] },
187-
disabled: nodes.some(nonMainInputs) || isReadOnly.value || !nodes.every(canPinNode),
165+
disabled: isReadOnly.value || !nodes.every((n) => usePinnedData(n).canPinNode(true)),
188166
},
189167
{
190168
id: 'copy',

packages/editor-ui/src/composables/usePinnedData.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useToast } from '@/composables/useToast';
22
import { useI18n } from '@/composables/useI18n';
33
import type { INodeExecutionData, IPinData } from 'n8n-workflow';
4-
import { jsonParse, jsonStringify } from 'n8n-workflow';
4+
import { jsonParse, jsonStringify, NodeConnectionType, NodeHelpers } from 'n8n-workflow';
55
import {
66
MAX_EXPECTED_REQUEST_SIZE,
77
MAX_PINNED_DATA_SIZE,
@@ -18,6 +18,8 @@ import { computed, unref } from 'vue';
1818
import { useRootStore } from '@/stores/n8nRoot.store';
1919
import { storeToRefs } from 'pinia';
2020
import { useNodeType } from '@/composables/useNodeType';
21+
import { useDataSchema } from './useDataSchema';
22+
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
2123

2224
export type PinDataSource =
2325
| 'pin-icon-click'
@@ -47,6 +49,7 @@ export function usePinnedData(
4749
const i18n = useI18n();
4850
const telemetry = useTelemetry();
4951
const externalHooks = useExternalHooks();
52+
const { getInputDataWithPinned } = useDataSchema();
5053

5154
const { pushRef } = storeToRefs(rootStore);
5255
const { isSubNodeType, isMultipleOutputsNodeType } = useNodeType({
@@ -73,6 +76,26 @@ export function usePinnedData(
7376
);
7477
});
7578

79+
function canPinNode(checkDataEmpty = false) {
80+
const targetNode = unref(node);
81+
if (targetNode === null) return false;
82+
83+
const nodeType = useNodeTypesStore().getNodeType(targetNode.type, targetNode.typeVersion);
84+
const dataToPin = getInputDataWithPinned(targetNode);
85+
86+
if (!nodeType || (checkDataEmpty && dataToPin.length === 0)) return false;
87+
88+
const workflow = workflowsStore.getCurrentWorkflow();
89+
const outputs = NodeHelpers.getNodeOutputs(workflow, targetNode, nodeType);
90+
const mainOutputs = outputs.filter((output) =>
91+
typeof output === 'string'
92+
? output === NodeConnectionType.Main
93+
: output.type === NodeConnectionType.Main,
94+
);
95+
96+
return mainOutputs.length === 1 && !PIN_DATA_NODE_TYPES_DENYLIST.includes(targetNode.type);
97+
}
98+
7699
function isValidJSON(data: string): boolean {
77100
try {
78101
JSON.parse(data);
@@ -246,6 +269,7 @@ export function usePinnedData(
246269
data,
247270
hasData,
248271
isValidNodeType,
272+
canPinNode,
249273
setData,
250274
onSetDataSuccess,
251275
onSetDataError,

0 commit comments

Comments
 (0)