fix(editor): Allow pinning of AI root nodes (#9060)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
oleg 2024-04-05 15:00:31 +02:00 committed by GitHub
parent caea27dbb5
commit 32df17104c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 127 additions and 121 deletions

View file

@ -134,7 +134,7 @@ describe('Data pinning', () => {
ndv.getters.pinDataButton().should('not.exist'); ndv.getters.pinDataButton().should('not.exist');
ndv.getters.editPinnedDataButton().should('be.visible'); ndv.getters.editPinnedDataButton().should('be.visible');
ndv.actions.setPinnedData([ ndv.actions.pastePinnedData([
{ {
test: '1'.repeat(Cypress.env('MAX_PINNED_DATA_SIZE')), test: '1'.repeat(Cypress.env('MAX_PINNED_DATA_SIZE')),
}, },

View file

@ -206,7 +206,7 @@ describe('Data mapping', () => {
workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); workflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
workflowPage.actions.openNode(MANUAL_TRIGGER_NODE_DISPLAY_NAME); workflowPage.actions.openNode(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
ndv.actions.setPinnedData([ ndv.actions.pastePinnedData([
{ {
input: [ input: [
{ {

View file

@ -324,7 +324,7 @@ describe('NDV', () => {
]; ];
/* prettier-ignore */ /* prettier-ignore */
workflowPage.actions.openNode('Get thread details1'); workflowPage.actions.openNode('Get thread details1');
ndv.actions.setPinnedData(PINNED_DATA); ndv.actions.pastePinnedData(PINNED_DATA);
ndv.actions.close(); ndv.actions.close();
workflowPage.actions.executeWorkflow(); workflowPage.actions.executeWorkflow();

View file

@ -155,6 +155,17 @@ export class NDV extends BasePage {
this.actions.savePinnedData(); this.actions.savePinnedData();
}, },
pastePinnedData: (data: object) => {
this.getters.editPinnedDataButton().click();
this.getters.pinnedDataEditor().click();
this.getters
.pinnedDataEditor()
.type('{selectall}{backspace}', { delay: 0 })
.paste(JSON.stringify(data));
this.actions.savePinnedData();
},
clearParameterInput: (parameterName: string) => { clearParameterInput: (parameterName: string) => {
this.getters.parameterInput(parameterName).type(`{selectall}{backspace}`); this.getters.parameterInput(parameterName).type(`{selectall}{backspace}`);
}, },

View file

@ -217,8 +217,8 @@
<div :class="[$style.editModeBody, 'ignore-key-press']"> <div :class="[$style.editModeBody, 'ignore-key-press']">
<JsonEditor <JsonEditor
:model-value="editMode.value" :model-value="editMode.value"
@update:model-value="ndvStore.setOutputPanelEditModeValue($event)"
:fill-parent="true" :fill-parent="true"
@update:model-value="ndvStore.setOutputPanelEditModeValue($event)"
/> />
</div> </div>
<div :class="$style.editModeFooter"> <div :class="$style.editModeFooter">
@ -725,45 +725,6 @@ export default defineComponent({
search: '', search: '',
}; };
}, },
mounted() {
this.init();
if (!this.isPaneTypeInput) {
this.showPinDataDiscoveryTooltip(this.jsonData);
}
this.ndvStore.setNDVBranchIndex({
pane: this.paneType as 'input' | 'output',
branchIndex: this.currentOutputIndex,
});
if (this.paneType === 'output') {
this.setDisplayMode();
this.activatePane();
}
if (this.hasRunError) {
const error = this.workflowRunData?.[this.node.name]?.[this.runIndex]?.error;
const errorsToTrack = ['unknown error'];
if (error && errorsToTrack.some((e) => error.message.toLowerCase().includes(e))) {
this.$telemetry.track(
`User encountered an error: "${error.message}"`,
{
node: this.node.type,
errorMessage: error.message,
nodeVersion: this.node.typeVersion,
n8nVersion: this.rootStore.versionCli,
},
{
withPostHog: true,
},
);
}
}
},
beforeUnmount() {
this.hidePinDataDiscoveryTooltip();
},
computed: { computed: {
...mapStores( ...mapStores(
useNodeTypesStore, useNodeTypesStore,
@ -803,21 +764,14 @@ export default defineComponent({
return this.nodeTypesStore.isTriggerNode(this.node.type); return this.nodeTypesStore.isTriggerNode(this.node.type);
}, },
canPinData(): boolean { canPinData(): boolean {
// Only "main" inputs can pin data
if (this.node === null) { if (this.node === null) {
return false; return false;
} }
const workflow = this.workflowsStore.getCurrentWorkflow(); const canPinNode = usePinnedData(this.node).canPinNode(false);
const workflowNode = workflow.getNode(this.node.name);
const inputs = NodeHelpers.getNodeInputs(workflow, workflowNode!, this.nodeType!);
const inputNames = NodeHelpers.getConnectionTypes(inputs);
const nonMainInputs = !!inputNames.find((inputName) => inputName !== NodeConnectionType.Main);
return ( return (
!nonMainInputs && canPinNode &&
!this.isPaneTypeInput && !this.isPaneTypeInput &&
this.pinnedData.isValidNodeType.value && this.pinnedData.isValidNodeType.value &&
!(this.binaryData && this.binaryData.length > 0) !(this.binaryData && this.binaryData.length > 0)
@ -1035,6 +989,87 @@ export default defineComponent({
return this.hasNodeRun && !this.inputData.length && this.search; return this.hasNodeRun && !this.inputData.length && this.search;
}, },
}, },
watch: {
node(newNode: INodeUi, prevNode: INodeUi) {
if (newNode.id === prevNode.id) return;
this.init();
},
hasNodeRun() {
if (this.paneType === 'output') this.setDisplayMode();
},
inputDataPage: {
handler(data: INodeExecutionData[]) {
if (this.paneType && data) {
this.ndvStore.setNDVPanelDataIsEmpty({
panel: this.paneType as 'input' | 'output',
isEmpty: data.every((item) => isEmpty(item.json)),
});
}
},
immediate: true,
deep: true,
},
jsonData(data: IDataObject[], prevData: IDataObject[]) {
if (isEqual(data, prevData)) return;
this.refreshDataSize();
this.showPinDataDiscoveryTooltip(data);
},
binaryData(newData: IBinaryKeyData[], prevData: IBinaryKeyData[]) {
if (newData.length && !prevData.length && this.displayMode !== 'binary') {
this.switchToBinary();
} else if (!newData.length && this.displayMode === 'binary') {
this.onDisplayModeChange('table');
}
},
currentOutputIndex(branchIndex: number) {
this.ndvStore.setNDVBranchIndex({
pane: this.paneType as 'input' | 'output',
branchIndex,
});
},
search(newSearch: string) {
this.$emit('search', newSearch);
},
},
mounted() {
this.init();
if (!this.isPaneTypeInput) {
this.showPinDataDiscoveryTooltip(this.jsonData);
}
this.ndvStore.setNDVBranchIndex({
pane: this.paneType as 'input' | 'output',
branchIndex: this.currentOutputIndex,
});
if (this.paneType === 'output') {
this.setDisplayMode();
this.activatePane();
}
if (this.hasRunError) {
const error = this.workflowRunData?.[this.node.name]?.[this.runIndex]?.error;
const errorsToTrack = ['unknown error'];
if (error && errorsToTrack.some((e) => error.message.toLowerCase().includes(e))) {
this.$telemetry.track(
`User encountered an error: "${error.message}"`,
{
node: this.node.type,
errorMessage: error.message,
nodeVersion: this.node.typeVersion,
n8nVersion: this.rootStore.versionCli,
},
{
withPostHog: true,
},
);
}
}
},
beforeUnmount() {
this.hidePinDataDiscoveryTooltip();
},
methods: { methods: {
getResolvedNodeOutputs() { getResolvedNodeOutputs() {
if (this.node && this.nodeType) { if (this.node && this.nodeType) {
@ -1500,48 +1535,6 @@ export default defineComponent({
document.dispatchEvent(new KeyboardEvent('keyup', { key: '/' })); document.dispatchEvent(new KeyboardEvent('keyup', { key: '/' }));
}, },
}, },
watch: {
node(newNode: INodeUi, prevNode: INodeUi) {
if (newNode.id === prevNode.id) return;
this.init();
},
hasNodeRun() {
if (this.paneType === 'output') this.setDisplayMode();
},
inputDataPage: {
handler(data: INodeExecutionData[]) {
if (this.paneType && data) {
this.ndvStore.setNDVPanelDataIsEmpty({
panel: this.paneType as 'input' | 'output',
isEmpty: data.every((item) => isEmpty(item.json)),
});
}
},
immediate: true,
deep: true,
},
jsonData(data: IDataObject[], prevData: IDataObject[]) {
if (isEqual(data, prevData)) return;
this.refreshDataSize();
this.showPinDataDiscoveryTooltip(data);
},
binaryData(newData: IBinaryKeyData[], prevData: IBinaryKeyData[]) {
if (newData.length && !prevData.length && this.displayMode !== 'binary') {
this.switchToBinary();
} else if (!newData.length && this.displayMode === 'binary') {
this.onDisplayModeChange('table');
}
},
currentOutputIndex(branchIndex: number) {
this.ndvStore.setNDVBranchIndex({
pane: this.paneType as 'input' | 'output',
branchIndex,
});
},
search(newSearch: string) {
this.$emit('search', newSearch);
},
},
}); });
</script> </script>

View file

@ -1,20 +1,15 @@
import type { XYPosition } from '@/Interface'; import type { XYPosition } from '@/Interface';
import { import { NOT_DUPLICATABE_NODE_TYPES, STICKY_NODE_TYPE } from '@/constants';
NOT_DUPLICATABE_NODE_TYPES,
PIN_DATA_NODE_TYPES_DENYLIST,
STICKY_NODE_TYPE,
} from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import type { IActionDropdownItem } from 'n8n-design-system/src/components/N8nActionDropdown/ActionDropdown.vue'; import type { IActionDropdownItem } from 'n8n-design-system/src/components/N8nActionDropdown/ActionDropdown.vue';
import { NodeHelpers, NodeConnectionType } from 'n8n-workflow';
import type { INode, INodeTypeDescription } from 'n8n-workflow'; import type { INode, INodeTypeDescription } from 'n8n-workflow';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { getMousePosition } from '../utils/nodeViewUtils'; import { getMousePosition } from '../utils/nodeViewUtils';
import { useI18n } from './useI18n'; import { useI18n } from './useI18n';
import { useDataSchema } from './useDataSchema'; import { usePinnedData } from './usePinnedData';
export type ContextMenuTarget = export type ContextMenuTarget =
| { source: 'canvas' } | { source: 'canvas' }
@ -47,7 +42,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const { getInputDataWithPinned } = useDataSchema();
const i18n = useI18n(); const i18n = useI18n();
const isReadOnly = computed( const isReadOnly = computed(
@ -83,13 +78,6 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
return canAddNodeOfType(nodeType); return canAddNodeOfType(nodeType);
}; };
const canPinNode = (node: INode): boolean => {
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
const dataToPin = getInputDataWithPinned(node);
if (!nodeType || dataToPin.length === 0) return false;
return nodeType.outputs.length === 1 && !PIN_DATA_NODE_TYPES_DENYLIST.includes(node.type);
};
const hasPinData = (node: INode): boolean => { const hasPinData = (node: INode): boolean => {
return !!workflowsStore.pinDataByNodeName(node.name); return !!workflowsStore.pinDataByNodeName(node.name);
}; };
@ -159,16 +147,6 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
...selectionActions, ...selectionActions,
]; ];
} else { } else {
const nonMainInputs = (node: INode) => {
const workflow = workflowsStore.getCurrentWorkflow();
const workflowNode = workflow.getNode(node.name);
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
const inputs = NodeHelpers.getNodeInputs(workflow, workflowNode!, nodeType!);
const inputNames = NodeHelpers.getConnectionTypes(inputs);
return !!inputNames.find((inputName) => inputName !== NodeConnectionType.Main);
};
const menuActions: IActionDropdownItem[] = [ const menuActions: IActionDropdownItem[] = [
!onlyStickies && { !onlyStickies && {
id: 'toggle_activation', id: 'toggle_activation',
@ -184,7 +162,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
? i18n.baseText('contextMenu.unpin', i18nOptions) ? i18n.baseText('contextMenu.unpin', i18nOptions)
: i18n.baseText('contextMenu.pin', i18nOptions), : i18n.baseText('contextMenu.pin', i18nOptions),
shortcut: { keys: ['p'] }, shortcut: { keys: ['p'] },
disabled: nodes.some(nonMainInputs) || isReadOnly.value || !nodes.every(canPinNode), disabled: isReadOnly.value || !nodes.every((n) => usePinnedData(n).canPinNode(true)),
}, },
{ {
id: 'copy', id: 'copy',

View file

@ -1,7 +1,7 @@
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import type { INodeExecutionData, IPinData } from 'n8n-workflow'; import type { INodeExecutionData, IPinData } from 'n8n-workflow';
import { jsonParse, jsonStringify } from 'n8n-workflow'; import { jsonParse, jsonStringify, NodeConnectionType, NodeHelpers } from 'n8n-workflow';
import { import {
MAX_EXPECTED_REQUEST_SIZE, MAX_EXPECTED_REQUEST_SIZE,
MAX_PINNED_DATA_SIZE, MAX_PINNED_DATA_SIZE,
@ -18,6 +18,8 @@ import { computed, unref } from 'vue';
import { useRootStore } from '@/stores/n8nRoot.store'; import { useRootStore } from '@/stores/n8nRoot.store';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useNodeType } from '@/composables/useNodeType'; import { useNodeType } from '@/composables/useNodeType';
import { useDataSchema } from './useDataSchema';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
export type PinDataSource = export type PinDataSource =
| 'pin-icon-click' | 'pin-icon-click'
@ -47,6 +49,7 @@ export function usePinnedData(
const i18n = useI18n(); const i18n = useI18n();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const externalHooks = useExternalHooks(); const externalHooks = useExternalHooks();
const { getInputDataWithPinned } = useDataSchema();
const { pushRef } = storeToRefs(rootStore); const { pushRef } = storeToRefs(rootStore);
const { isSubNodeType, isMultipleOutputsNodeType } = useNodeType({ const { isSubNodeType, isMultipleOutputsNodeType } = useNodeType({
@ -73,6 +76,26 @@ export function usePinnedData(
); );
}); });
function canPinNode(checkDataEmpty = false) {
const targetNode = unref(node);
if (targetNode === null) return false;
const nodeType = useNodeTypesStore().getNodeType(targetNode.type, targetNode.typeVersion);
const dataToPin = getInputDataWithPinned(targetNode);
if (!nodeType || (checkDataEmpty && dataToPin.length === 0)) return false;
const workflow = workflowsStore.getCurrentWorkflow();
const outputs = NodeHelpers.getNodeOutputs(workflow, targetNode, nodeType);
const mainOutputs = outputs.filter((output) =>
typeof output === 'string'
? output === NodeConnectionType.Main
: output.type === NodeConnectionType.Main,
);
return mainOutputs.length === 1 && !PIN_DATA_NODE_TYPES_DENYLIST.includes(targetNode.type);
}
function isValidJSON(data: string): boolean { function isValidJSON(data: string): boolean {
try { try {
JSON.parse(data); JSON.parse(data);
@ -246,6 +269,7 @@ export function usePinnedData(
data, data,
hasData, hasData,
isValidNodeType, isValidNodeType,
canPinNode,
setData, setData,
onSetDataSuccess, onSetDataSuccess,
onSetDataError, onSetDataError,