mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 10:31:15 +00:00
feat(editor): Refactor the executable node logic (no-changelog) (#16848)
This commit is contained in:
@@ -19,7 +19,6 @@ import TriggerPanel from './TriggerPanel.vue';
|
|||||||
import {
|
import {
|
||||||
APP_MODALS_ELEMENT_ID,
|
APP_MODALS_ELEMENT_ID,
|
||||||
BASE_NODE_SURVEY_URL,
|
BASE_NODE_SURVEY_URL,
|
||||||
EnterpriseEditionFeature,
|
|
||||||
EXECUTABLE_TRIGGER_NODE_TYPES,
|
EXECUTABLE_TRIGGER_NODE_TYPES,
|
||||||
MODAL_CONFIRM,
|
MODAL_CONFIRM,
|
||||||
START_NODE_TYPE,
|
START_NODE_TYPE,
|
||||||
@@ -31,7 +30,6 @@ import { dataPinningEventBus, ndvEventBus } 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';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
|
||||||
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
import { useMessage } from '@/composables/useMessage';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
@@ -76,7 +74,6 @@ const pinnedData = usePinnedData(activeNode);
|
|||||||
const workflowActivate = useWorkflowActivate();
|
const workflowActivate = useWorkflowActivate();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const settingsStore = useSettingsStore();
|
|
||||||
const deviceSupport = useDeviceSupport();
|
const deviceSupport = useDeviceSupport();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
@@ -333,25 +330,9 @@ const blockUi = computed(
|
|||||||
() => workflowsStore.isWorkflowRunning || isExecutionWaitingForWebhook.value,
|
() => workflowsStore.isWorkflowRunning || isExecutionWaitingForWebhook.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
const foreignCredentials = computed(() => {
|
const foreignCredentials = computed(() =>
|
||||||
const credentials = activeNode.value?.credentials;
|
nodeHelpers.getForeignCredentialsIfSharingEnabled(activeNode.value?.credentials),
|
||||||
const usedCredentials = workflowsStore.usedCredentials;
|
);
|
||||||
|
|
||||||
const foreignCredentialsArray: string[] = [];
|
|
||||||
if (credentials && settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]) {
|
|
||||||
Object.values(credentials).forEach((credential) => {
|
|
||||||
if (
|
|
||||||
credential.id &&
|
|
||||||
usedCredentials[credential.id] &&
|
|
||||||
!usedCredentials[credential.id].currentUserHasAccess
|
|
||||||
) {
|
|
||||||
foreignCredentialsArray.push(credential.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return foreignCredentialsArray;
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasForeignCredential = computed(() => foreignCredentials.value.length > 0);
|
const hasForeignCredential = computed(() => foreignCredentials.value.length > 0);
|
||||||
|
|
||||||
@@ -850,7 +831,6 @@ onBeforeUnmount(() => {
|
|||||||
:event-bus="settingsEventBus"
|
:event-bus="settingsEventBus"
|
||||||
:dragging="isDragging"
|
:dragging="isDragging"
|
||||||
:push-ref="pushRef"
|
:push-ref="pushRef"
|
||||||
:node-type="activeNodeType"
|
|
||||||
:foreign-credentials="foreignCredentials"
|
:foreign-credentials="foreignCredentials"
|
||||||
:read-only="readOnly"
|
:read-only="readOnly"
|
||||||
:block-u-i="blockUi && showTriggerPanel"
|
:block-u-i="blockUi && showTriggerPanel"
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTemplateRef, computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { useTemplateRef, computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import type {
|
import type {
|
||||||
INodeTypeDescription,
|
|
||||||
INodeParameters,
|
INodeParameters,
|
||||||
INodeProperties,
|
INodeProperties,
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
NodeParameterValue,
|
NodeParameterValue,
|
||||||
INodeCredentialDescription,
|
INodeCredentialDescription,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeHelpers, NodeConnectionTypes, deepCopy } from 'n8n-workflow';
|
import { NodeHelpers, deepCopy } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
CurlToJSONResponse,
|
CurlToJSONResponse,
|
||||||
INodeUi,
|
INodeUi,
|
||||||
@@ -49,7 +48,6 @@ const props = withDefaults(
|
|||||||
eventBus: EventBus;
|
eventBus: EventBus;
|
||||||
dragging: boolean;
|
dragging: boolean;
|
||||||
pushRef: string;
|
pushRef: string;
|
||||||
nodeType: INodeTypeDescription | null;
|
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
foreignCredentials: string[];
|
foreignCredentials: string[];
|
||||||
blockUI: boolean;
|
blockUI: boolean;
|
||||||
@@ -134,31 +132,17 @@ const isReadOnly = computed(
|
|||||||
);
|
);
|
||||||
const node = computed(() => props.activeNode ?? ndvStore.activeNode);
|
const node = computed(() => props.activeNode ?? ndvStore.activeNode);
|
||||||
|
|
||||||
|
const nodeType = computed(() =>
|
||||||
|
node.value ? nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion) : null,
|
||||||
|
);
|
||||||
|
|
||||||
const isTriggerNode = computed(() => !!node.value && nodeTypesStore.isTriggerNode(node.value.type));
|
const isTriggerNode = computed(() => !!node.value && nodeTypesStore.isTriggerNode(node.value.type));
|
||||||
|
|
||||||
const isToolNode = computed(() => !!node.value && nodeTypesStore.isToolNode(node.value.type));
|
const isToolNode = computed(() => !!node.value && nodeTypesStore.isToolNode(node.value.type));
|
||||||
|
|
||||||
const isExecutable = computed(() => {
|
const isExecutable = computed(() =>
|
||||||
if (props.nodeType && node.value) {
|
nodeHelpers.isNodeExecutable(node.value, props.executable, props.foreignCredentials),
|
||||||
const workflowNode = currentWorkflowInstance.value.getNode(node.value.name);
|
);
|
||||||
const inputs = NodeHelpers.getNodeInputs(
|
|
||||||
currentWorkflowInstance.value,
|
|
||||||
workflowNode!,
|
|
||||||
props.nodeType,
|
|
||||||
);
|
|
||||||
const inputNames = NodeHelpers.getConnectionTypes(inputs);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!inputNames.includes(NodeConnectionTypes.Main) &&
|
|
||||||
!isToolNode.value &&
|
|
||||||
!isTriggerNode.value
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return props.executable || props.foreignCredentials.length > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const nodeTypeVersions = computed(() => {
|
const nodeTypeVersions = computed(() => {
|
||||||
if (!node.value) return [];
|
if (!node.value) return [];
|
||||||
@@ -186,7 +170,7 @@ const executeButtonTooltip = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nodeVersionTag = computed(() => {
|
const nodeVersionTag = computed(() => {
|
||||||
if (!props.nodeType || props.nodeType.hidden) {
|
if (!nodeType.value || nodeType.value.hidden) {
|
||||||
return i18n.baseText('nodeSettings.deprecated');
|
return i18n.baseText('nodeSettings.deprecated');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,11 +184,11 @@ const nodeVersionTag = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const parameters = computed(() => {
|
const parameters = computed(() => {
|
||||||
if (props.nodeType === null) {
|
if (nodeType.value === null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return props.nodeType?.properties ?? [];
|
return nodeType.value?.properties ?? [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const parametersSetting = computed(() => parameters.value.filter((item) => item.isNodeSetting));
|
const parametersSetting = computed(() => parameters.value.filter((item) => item.isNodeSetting));
|
||||||
@@ -219,7 +203,7 @@ const parametersNoneSetting = computed(() => {
|
|||||||
const isDisplayingCredentials = computed(
|
const isDisplayingCredentials = computed(
|
||||||
() =>
|
() =>
|
||||||
credentialsStore
|
credentialsStore
|
||||||
.getCredentialTypesNodeDescriptions('', props.nodeType)
|
.getCredentialTypesNodeDescriptions('', nodeType.value)
|
||||||
.filter((credentialTypeDescription) => displayCredentials(credentialTypeDescription)).length >
|
.filter((credentialTypeDescription) => displayCredentials(credentialTypeDescription)).length >
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@@ -292,19 +276,19 @@ const valueChanged = (parameterData: IUpdateInformation) => {
|
|||||||
};
|
};
|
||||||
emit('valueChanged', sendData);
|
emit('valueChanged', sendData);
|
||||||
} else if (parameterData.name === 'parameters') {
|
} else if (parameterData.name === 'parameters') {
|
||||||
const nodeType = nodeTypesStore.getNodeType(_node.type, _node.typeVersion);
|
const _nodeType = nodeTypesStore.getNodeType(_node.type, _node.typeVersion);
|
||||||
if (!nodeType) {
|
if (!_nodeType) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get only the parameters which are different to the defaults
|
// Get only the parameters which are different to the defaults
|
||||||
let nodeParameters = NodeHelpers.getNodeParameters(
|
let nodeParameters = NodeHelpers.getNodeParameters(
|
||||||
nodeType.properties,
|
_nodeType.properties,
|
||||||
_node.parameters,
|
_node.parameters,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
_node,
|
_node,
|
||||||
nodeType,
|
_nodeType,
|
||||||
);
|
);
|
||||||
|
|
||||||
const oldNodeParameters = Object.assign({}, nodeParameters);
|
const oldNodeParameters = Object.assign({}, nodeParameters);
|
||||||
@@ -321,7 +305,7 @@ const valueChanged = (parameterData: IUpdateInformation) => {
|
|||||||
parameterName,
|
parameterName,
|
||||||
newValue,
|
newValue,
|
||||||
nodeParameters,
|
nodeParameters,
|
||||||
nodeType,
|
_nodeType,
|
||||||
_node.typeVersion,
|
_node.typeVersion,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -337,12 +321,12 @@ const valueChanged = (parameterData: IUpdateInformation) => {
|
|||||||
// Get the parameters with the now new defaults according to the
|
// Get the parameters with the now new defaults according to the
|
||||||
// from the user actually defined parameters
|
// from the user actually defined parameters
|
||||||
nodeParameters = NodeHelpers.getNodeParameters(
|
nodeParameters = NodeHelpers.getNodeParameters(
|
||||||
nodeType.properties,
|
_nodeType.properties,
|
||||||
nodeParameters as INodeParameters,
|
nodeParameters as INodeParameters,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
_node,
|
_node,
|
||||||
nodeType,
|
_nodeType,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const key of Object.keys(nodeParameters as object)) {
|
for (const key of Object.keys(nodeParameters as object)) {
|
||||||
@@ -581,7 +565,7 @@ const setNodeValues = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.nodeType !== null) {
|
if (nodeType.value !== null) {
|
||||||
nodeValid.value = true;
|
nodeValid.value = true;
|
||||||
|
|
||||||
const foundNodeSettings = [];
|
const foundNodeSettings = [];
|
||||||
@@ -725,7 +709,7 @@ onMounted(() => {
|
|||||||
setNodeValues();
|
setNodeValues();
|
||||||
props.eventBus?.on('openSettings', openSettings);
|
props.eventBus?.on('openSettings', openSettings);
|
||||||
if (node.value !== null) {
|
if (node.value !== null) {
|
||||||
nodeHelpers.updateNodeParameterIssues(node.value, props.nodeType);
|
nodeHelpers.updateNodeParameterIssues(node.value, nodeType.value);
|
||||||
}
|
}
|
||||||
importCurlEventBus.on('setHttpNodeParameters', setHttpNodeParameters);
|
importCurlEventBus.on('setHttpNodeParameters', setHttpNodeParameters);
|
||||||
ndvEventBus.on('updateParameterValue', valueChanged);
|
ndvEventBus.on('updateParameterValue', valueChanged);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import NodeSettings from '@/components/NodeSettings.vue';
|
import NodeSettings from '@/components/NodeSettings.vue';
|
||||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||||
import { type IUpdateInformation } from '@/Interface';
|
import { type IUpdateInformation } from '@/Interface';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { createEventBus } from '@n8n/utils/event-bus';
|
import { createEventBus } from '@n8n/utils/event-bus';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
@@ -12,17 +11,10 @@ const { nodeId, noWheel } = defineProps<{ nodeId: string; noWheel?: boolean }>()
|
|||||||
defineSlots<{ actions?: {} }>();
|
defineSlots<{ actions?: {} }>();
|
||||||
|
|
||||||
const settingsEventBus = createEventBus();
|
const settingsEventBus = createEventBus();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const { renameNode } = useCanvasOperations();
|
const { renameNode } = useCanvasOperations();
|
||||||
|
|
||||||
const activeNode = computed(() => workflowsStore.getNodeById(nodeId));
|
const activeNode = computed(() => workflowsStore.getNodeById(nodeId));
|
||||||
const activeNodeType = computed(() => {
|
|
||||||
if (activeNode.value) {
|
|
||||||
return nodeTypesStore.getNodeType(activeNode.value.type, activeNode.value.typeVersion);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleValueChanged(parameterData: IUpdateInformation) {
|
function handleValueChanged(parameterData: IUpdateInformation) {
|
||||||
if (parameterData.name === 'name' && parameterData.oldValue) {
|
if (parameterData.name === 'name' && parameterData.oldValue) {
|
||||||
@@ -36,7 +28,6 @@ function handleValueChanged(parameterData: IUpdateInformation) {
|
|||||||
:event-bus="settingsEventBus"
|
:event-bus="settingsEventBus"
|
||||||
:dragging="false"
|
:dragging="false"
|
||||||
:active-node="activeNode"
|
:active-node="activeNode"
|
||||||
:node-type="activeNodeType"
|
|
||||||
push-ref=""
|
push-ref=""
|
||||||
:foreign-credentials="[]"
|
:foreign-credentials="[]"
|
||||||
:read-only="false"
|
:read-only="false"
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import type { INode, INodeTypeDescription, Workflow } from 'n8n-workflow';
|
import {
|
||||||
|
NodeConnectionTypes,
|
||||||
|
NodeHelpers,
|
||||||
|
type INode,
|
||||||
|
type INodeTypeDescription,
|
||||||
|
type Workflow,
|
||||||
|
} from 'n8n-workflow';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
import { createTestNode } from '@/__tests__/mocks';
|
import { createTestNode, createMockEnterpriseSettings } from '@/__tests__/mocks';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { CUSTOM_API_CALL_KEY } from '@/constants';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { CUSTOM_API_CALL_KEY, EnterpriseEditionFeature } from '@/constants';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
import type { ExecutionStatus, IRunData } from 'n8n-workflow';
|
import type { ExecutionStatus, IRunData } from 'n8n-workflow';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { INodeUi, IUsedCredential } from '@/Interface';
|
||||||
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
|
||||||
describe('useNodeHelpers()', () => {
|
describe('useNodeHelpers()', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -18,6 +28,293 @@ describe('useNodeHelpers()', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isNodeExecutable()', () => {
|
||||||
|
it('should return true if the node is null but explicitly executable', () => {
|
||||||
|
const { isNodeExecutable } = useNodeHelpers();
|
||||||
|
|
||||||
|
const result = isNodeExecutable(null, true, []);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false if node has no Main input and is not trigger or tool', () => {
|
||||||
|
const { isNodeExecutable } = useNodeHelpers();
|
||||||
|
|
||||||
|
const node: INodeUi = {
|
||||||
|
id: 'node-id',
|
||||||
|
name: 'Code',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkflow = {
|
||||||
|
id: 'workflow-id',
|
||||||
|
getNode: () => node,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
|
||||||
|
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
|
||||||
|
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(false);
|
||||||
|
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(false);
|
||||||
|
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
|
||||||
|
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([NodeConnectionTypes.AiDocument]);
|
||||||
|
|
||||||
|
const result = isNodeExecutable(node, true, []);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true if node has Main input and is marked executable', () => {
|
||||||
|
const { isNodeExecutable } = useNodeHelpers();
|
||||||
|
|
||||||
|
const node: INodeUi = {
|
||||||
|
id: 'node-id',
|
||||||
|
name: 'Code',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkflow = {
|
||||||
|
getNode: () => node,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
|
||||||
|
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
|
||||||
|
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(false);
|
||||||
|
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(false);
|
||||||
|
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
|
||||||
|
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([NodeConnectionTypes.Main]);
|
||||||
|
|
||||||
|
const result = isNodeExecutable(node, true, []);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true if node has foreign credentials even if not marked executable', () => {
|
||||||
|
const { isNodeExecutable } = useNodeHelpers();
|
||||||
|
|
||||||
|
const node: INodeUi = {
|
||||||
|
id: 'node-id',
|
||||||
|
name: 'Code',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkflow = {
|
||||||
|
getNode: () => node,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
|
||||||
|
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
|
||||||
|
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(false);
|
||||||
|
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(false);
|
||||||
|
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
|
||||||
|
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([NodeConnectionTypes.Main]);
|
||||||
|
|
||||||
|
const result = isNodeExecutable(node, false, ['foreign-cred-id']);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for trigger nodes regardless of inputs', () => {
|
||||||
|
const { isNodeExecutable } = useNodeHelpers();
|
||||||
|
|
||||||
|
const triggerNode: INodeUi = {
|
||||||
|
id: 'node-id',
|
||||||
|
name: 'Manual Trigger',
|
||||||
|
type: 'n8n-nodes-base.manualTrigger',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkflow = {
|
||||||
|
getNode: () => triggerNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
|
||||||
|
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
|
||||||
|
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(true);
|
||||||
|
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(false);
|
||||||
|
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
|
||||||
|
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([]);
|
||||||
|
|
||||||
|
const result = isNodeExecutable(triggerNode, true, []);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for tool nodes regardless of inputs', () => {
|
||||||
|
const { isNodeExecutable } = useNodeHelpers();
|
||||||
|
|
||||||
|
const toolNode: INodeUi = {
|
||||||
|
id: 'node-id',
|
||||||
|
name: 'Tool Node',
|
||||||
|
type: 'n8n-nodes-base.ai-tool',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkflow = {
|
||||||
|
getNode: () => toolNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
|
||||||
|
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
|
||||||
|
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(false);
|
||||||
|
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(true);
|
||||||
|
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
|
||||||
|
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([]);
|
||||||
|
|
||||||
|
const result = isNodeExecutable(toolNode, true, []);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true if node is structurally valid and has foreign credentials, even if not executable', () => {
|
||||||
|
const { isNodeExecutable } = useNodeHelpers();
|
||||||
|
|
||||||
|
const node: INodeUi = {
|
||||||
|
id: 'node-id',
|
||||||
|
name: 'Code',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkflow = {
|
||||||
|
getNode: () => node,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
|
||||||
|
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
|
||||||
|
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(false);
|
||||||
|
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(false);
|
||||||
|
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
|
||||||
|
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([NodeConnectionTypes.Main]);
|
||||||
|
|
||||||
|
const result = isNodeExecutable(node, false, ['cred-1']);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getForeignCredentialsIfSharingEnabled()', () => {
|
||||||
|
it('should return an empty array when user has the wrong license', () => {
|
||||||
|
const { getForeignCredentialsIfSharingEnabled } = useNodeHelpers();
|
||||||
|
|
||||||
|
const credentialWithoutAccess: IUsedCredential = {
|
||||||
|
id: faker.string.alphanumeric(10),
|
||||||
|
credentialType: 'generic',
|
||||||
|
name: faker.lorem.words(2),
|
||||||
|
currentUserHasAccess: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedStore(useSettingsStore).isEnterpriseFeatureEnabled = createMockEnterpriseSettings({
|
||||||
|
[EnterpriseEditionFeature.Sharing]: false,
|
||||||
|
});
|
||||||
|
mockedStore(useWorkflowsStore).usedCredentials = {
|
||||||
|
[credentialWithoutAccess.id]: credentialWithoutAccess,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getForeignCredentialsIfSharingEnabled({
|
||||||
|
[credentialWithoutAccess.id]: {
|
||||||
|
id: credentialWithoutAccess.id,
|
||||||
|
name: credentialWithoutAccess.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an empty array when credentials are undefined', () => {
|
||||||
|
const { getForeignCredentialsIfSharingEnabled } = useNodeHelpers();
|
||||||
|
|
||||||
|
mockedStore(useSettingsStore).isEnterpriseFeatureEnabled = createMockEnterpriseSettings({
|
||||||
|
[EnterpriseEditionFeature.Sharing]: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getForeignCredentialsIfSharingEnabled(undefined);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an empty array when user has access to all credentials', () => {
|
||||||
|
const { getForeignCredentialsIfSharingEnabled } = useNodeHelpers();
|
||||||
|
|
||||||
|
const credentialWithAccess1: IUsedCredential = {
|
||||||
|
id: faker.string.alphanumeric(10),
|
||||||
|
credentialType: 'generic',
|
||||||
|
name: faker.lorem.words(2),
|
||||||
|
currentUserHasAccess: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const credentialWithAccess2: IUsedCredential = {
|
||||||
|
id: faker.string.alphanumeric(10),
|
||||||
|
credentialType: 'generic',
|
||||||
|
name: faker.lorem.words(2),
|
||||||
|
currentUserHasAccess: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedStore(useSettingsStore).isEnterpriseFeatureEnabled = createMockEnterpriseSettings({
|
||||||
|
[EnterpriseEditionFeature.Sharing]: true,
|
||||||
|
});
|
||||||
|
mockedStore(useWorkflowsStore).usedCredentials = {
|
||||||
|
[credentialWithAccess1.id]: credentialWithAccess1,
|
||||||
|
[credentialWithAccess2.id]: credentialWithAccess2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getForeignCredentialsIfSharingEnabled({
|
||||||
|
[credentialWithAccess1.id]: {
|
||||||
|
id: credentialWithAccess1.id,
|
||||||
|
name: credentialWithAccess1.name,
|
||||||
|
},
|
||||||
|
[credentialWithAccess2.id]: {
|
||||||
|
id: credentialWithAccess2.id,
|
||||||
|
name: credentialWithAccess2.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an array of foreign credentials', () => {
|
||||||
|
const { getForeignCredentialsIfSharingEnabled } = useNodeHelpers();
|
||||||
|
|
||||||
|
const credentialWithAccess: IUsedCredential = {
|
||||||
|
id: faker.string.alphanumeric(10),
|
||||||
|
credentialType: 'generic',
|
||||||
|
name: faker.lorem.words(2),
|
||||||
|
currentUserHasAccess: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const credentialWithoutAccess: IUsedCredential = {
|
||||||
|
id: faker.string.alphanumeric(10),
|
||||||
|
credentialType: 'generic',
|
||||||
|
name: faker.lorem.words(2),
|
||||||
|
currentUserHasAccess: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedStore(useSettingsStore).isEnterpriseFeatureEnabled = createMockEnterpriseSettings({
|
||||||
|
[EnterpriseEditionFeature.Sharing]: true,
|
||||||
|
});
|
||||||
|
mockedStore(useWorkflowsStore).usedCredentials = {
|
||||||
|
[credentialWithAccess.id]: credentialWithAccess,
|
||||||
|
[credentialWithoutAccess.id]: credentialWithoutAccess,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getForeignCredentialsIfSharingEnabled({
|
||||||
|
[credentialWithAccess.id]: {
|
||||||
|
id: credentialWithAccess.id,
|
||||||
|
name: credentialWithAccess.name,
|
||||||
|
},
|
||||||
|
[credentialWithoutAccess.id]: {
|
||||||
|
id: credentialWithoutAccess.id,
|
||||||
|
name: credentialWithoutAccess.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual([credentialWithoutAccess.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isCustomApiCallSelected', () => {
|
describe('isCustomApiCallSelected', () => {
|
||||||
test('should return `true` when resource includes `CUSTOM_API_CALL_KEY`', () => {
|
test('should return `true` when resource includes `CUSTOM_API_CALL_KEY`', () => {
|
||||||
const nodeValues = {
|
const nodeValues = {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useHistoryStore } from '@/stores/history.store';
|
import { useHistoryStore } from '@/stores/history.store';
|
||||||
import { CUSTOM_API_CALL_KEY, PLACEHOLDER_FILLED_AT_EXECUTION_TIME } from '@/constants';
|
import {
|
||||||
|
CUSTOM_API_CALL_KEY,
|
||||||
|
EnterpriseEditionFeature,
|
||||||
|
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
||||||
|
} from '@/constants';
|
||||||
|
|
||||||
import { NodeHelpers, NodeConnectionTypes } from 'n8n-workflow';
|
import { NodeHelpers, NodeConnectionTypes } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
@@ -26,6 +30,7 @@ import type {
|
|||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
IRunExecutionData,
|
IRunExecutionData,
|
||||||
NodeHint,
|
NodeHint,
|
||||||
|
INodeCredentials,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -47,6 +52,7 @@ import { EnableNodeToggleCommand } from '@/models/history';
|
|||||||
import { useTelemetry } from './useTelemetry';
|
import { useTelemetry } from './useTelemetry';
|
||||||
import { hasPermission } from '@/utils/rbac/permissions';
|
import { hasPermission } from '@/utils/rbac/permissions';
|
||||||
import { useCanvasStore } from '@/stores/canvas.store';
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
declare namespace HttpRequestNode {
|
declare namespace HttpRequestNode {
|
||||||
namespace V2 {
|
namespace V2 {
|
||||||
@@ -63,6 +69,7 @@ export function useNodeHelpers() {
|
|||||||
const historyStore = useHistoryStore();
|
const historyStore = useHistoryStore();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const canvasStore = useCanvasStore();
|
const canvasStore = useCanvasStore();
|
||||||
|
|
||||||
@@ -92,6 +99,70 @@ export function useNodeHelpers() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a given node is considered executable in the workflow editor.
|
||||||
|
*
|
||||||
|
* A node is considered executable if:
|
||||||
|
* - It structurally qualifies for execution (e.g. is a trigger, tool, or has a 'Main' input),
|
||||||
|
* AND
|
||||||
|
* - It is either explicitly marked as `executable`, OR uses foreign credentials
|
||||||
|
* (credentials the current user cannot access, allowed under Workflow Sharing).
|
||||||
|
*
|
||||||
|
* @param node The node to check
|
||||||
|
* @param executable Whether the node is in a state that allows execution (e.g. not readonly)
|
||||||
|
* @param foreignCredentials List of credential IDs that the current user cannot access
|
||||||
|
*/
|
||||||
|
function isNodeExecutable(
|
||||||
|
node: INodeUi | null,
|
||||||
|
executable: boolean | undefined,
|
||||||
|
foreignCredentials: string[],
|
||||||
|
): boolean {
|
||||||
|
const nodeType = node ? nodeTypesStore.getNodeType(node.type, node.typeVersion) : null;
|
||||||
|
if (node && nodeType) {
|
||||||
|
const currentWorkflowInstance = workflowsStore.getCurrentWorkflow();
|
||||||
|
const workflowNode = currentWorkflowInstance.getNode(node.name);
|
||||||
|
|
||||||
|
const isTriggerNode = !!node && nodeTypesStore.isTriggerNode(node.type);
|
||||||
|
const isToolNode = !!node && nodeTypesStore.isToolNode(node.type);
|
||||||
|
|
||||||
|
if (workflowNode) {
|
||||||
|
const inputs = NodeHelpers.getNodeInputs(currentWorkflowInstance, workflowNode, nodeType);
|
||||||
|
const inputNames = NodeHelpers.getConnectionTypes(inputs);
|
||||||
|
|
||||||
|
if (!inputNames.includes(NodeConnectionTypes.Main) && !isToolNode && !isTriggerNode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(executable || foreignCredentials.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of credential IDs that the current user does not have access to,
|
||||||
|
* if the Sharing feature is enabled.
|
||||||
|
*
|
||||||
|
* These are considered "foreign" credentials: the user can't view or manage them,
|
||||||
|
* but can still execute workflows that use them.
|
||||||
|
*/
|
||||||
|
function getForeignCredentialsIfSharingEnabled(
|
||||||
|
credentials: INodeCredentials | undefined,
|
||||||
|
): string[] {
|
||||||
|
if (
|
||||||
|
!credentials ||
|
||||||
|
!settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedCredentials = workflowsStore.usedCredentials;
|
||||||
|
|
||||||
|
return Object.values(credentials)
|
||||||
|
.map(({ id }) => id)
|
||||||
|
.filter((id) => id !== null)
|
||||||
|
.filter((id) => id in usedCredentials && !usedCredentials[id]?.currentUserHasAccess);
|
||||||
|
}
|
||||||
|
|
||||||
function getParameterValue(nodeValues: INodeParameters, parameterName: string, path: string) {
|
function getParameterValue(nodeValues: INodeParameters, parameterName: string, path: string) {
|
||||||
return get(nodeValues, path ? path + '.' + parameterName : parameterName);
|
return get(nodeValues, path ? path + '.' + parameterName : parameterName);
|
||||||
}
|
}
|
||||||
@@ -1005,6 +1076,8 @@ export function useNodeHelpers() {
|
|||||||
return {
|
return {
|
||||||
hasProxyAuth,
|
hasProxyAuth,
|
||||||
isCustomApiCallSelected,
|
isCustomApiCallSelected,
|
||||||
|
isNodeExecutable,
|
||||||
|
getForeignCredentialsIfSharingEnabled,
|
||||||
getParameterValue,
|
getParameterValue,
|
||||||
displayParameter,
|
displayParameter,
|
||||||
getNodeIssues,
|
getNodeIssues,
|
||||||
|
|||||||
Reference in New Issue
Block a user