mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Support partial executions of tool nodes (#14945)
This commit is contained in:
@@ -318,6 +318,7 @@ describe('LoadNodesAndCredentials', () => {
|
|||||||
group: ['input'],
|
group: ['input'],
|
||||||
inputs: [],
|
inputs: [],
|
||||||
outputs: ['ai_tool'],
|
outputs: ['ai_tool'],
|
||||||
|
usableAsTool: true,
|
||||||
properties: [
|
properties: [
|
||||||
{
|
{
|
||||||
default: 'A test node',
|
default: 'A test node',
|
||||||
@@ -370,6 +371,7 @@ describe('LoadNodesAndCredentials', () => {
|
|||||||
inputs: [],
|
inputs: [],
|
||||||
outputs: ['ai_tool'],
|
outputs: ['ai_tool'],
|
||||||
description: 'A test node',
|
description: 'A test node',
|
||||||
|
usableAsTool: true,
|
||||||
properties: [
|
properties: [
|
||||||
{
|
{
|
||||||
displayName: 'Description',
|
displayName: 'Description',
|
||||||
|
|||||||
@@ -317,6 +317,8 @@ export class LoadNodesAndCredentials {
|
|||||||
} as INodeTypeBaseDescription)
|
} as INodeTypeBaseDescription)
|
||||||
: deepCopy(usableNode);
|
: deepCopy(usableNode);
|
||||||
const wrapped = this.convertNodeToAiTool({ description }).description;
|
const wrapped = this.convertNodeToAiTool({ description }).description;
|
||||||
|
// TODO: Remove this when we support partial execution on all tool nodes
|
||||||
|
wrapped.usableAsTool = true;
|
||||||
|
|
||||||
this.types.nodes.push(wrapped);
|
this.types.nodes.push(wrapped);
|
||||||
this.known.nodes[wrapped.name] = { ...this.known.nodes[usableNode.name] };
|
this.known.nodes[wrapped.name] = { ...this.known.nodes[usableNode.name] };
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import FromAiParametersModal from '@/components/FromAiParametersModal.vue';
|
||||||
|
import { FROM_AI_PARAMETERS_MODAL_KEY, STORES } from '@/constants';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||||
|
|
||||||
|
const ModalStub = {
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<slot name="header" />
|
||||||
|
<slot name="title" />
|
||||||
|
<slot name="content" />
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('vue-router');
|
||||||
|
|
||||||
|
vi.mocked(useRouter);
|
||||||
|
|
||||||
|
const mockNode = {
|
||||||
|
id: 'id1',
|
||||||
|
name: 'Test Node',
|
||||||
|
parameters: {
|
||||||
|
testBoolean: "={{ $fromAI('testBoolean', ``, 'boolean') }}",
|
||||||
|
testParam: "={{ $fromAi('testParam', ``, 'string') }}",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockParentNode = {
|
||||||
|
name: 'Parent Node',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRunData = {
|
||||||
|
data: {
|
||||||
|
resultData: {
|
||||||
|
runData: {
|
||||||
|
['Test Node']: [
|
||||||
|
{
|
||||||
|
inputOverride: {
|
||||||
|
[NodeConnectionTypes.AiTool]: [[{ json: { testParam: 'override' } }]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkflow = {
|
||||||
|
id: 'test-workflow',
|
||||||
|
getChildNodes: () => ['Parent Node'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderModal = createComponentRenderer(FromAiParametersModal);
|
||||||
|
let pinia: ReturnType<typeof createTestingPinia>;
|
||||||
|
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||||
|
let parameterOverridesStore: ReturnType<typeof useParameterOverridesStore>;
|
||||||
|
describe('FromAiParametersModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
pinia = createTestingPinia({
|
||||||
|
initialState: {
|
||||||
|
[STORES.UI]: {
|
||||||
|
modalsById: {
|
||||||
|
[FROM_AI_PARAMETERS_MODAL_KEY]: {
|
||||||
|
open: true,
|
||||||
|
data: {
|
||||||
|
nodeName: 'Test Node',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modalStack: [FROM_AI_PARAMETERS_MODAL_KEY],
|
||||||
|
},
|
||||||
|
[STORES.WORKFLOWS]: {
|
||||||
|
workflow: mockWorkflow,
|
||||||
|
workflowExecutionData: mockRunData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
workflowsStore = useWorkflowsStore();
|
||||||
|
workflowsStore.getNodeByName = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((name: string) => (name === 'Test Node' ? mockNode : mockParentNode));
|
||||||
|
workflowsStore.getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
|
||||||
|
parameterOverridesStore = useParameterOverridesStore();
|
||||||
|
parameterOverridesStore.clearParameterOverrides = vi.fn();
|
||||||
|
parameterOverridesStore.addParameterOverrides = vi.fn();
|
||||||
|
parameterOverridesStore.substituteParameters = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders correctly with node data', () => {
|
||||||
|
const { getByTitle } = renderModal({
|
||||||
|
props: {
|
||||||
|
modalName: FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
|
data: {
|
||||||
|
nodeName: 'Test Node',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Modal: ModalStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pinia,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTitle('Test Test Node')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses run data when available as initial values', async () => {
|
||||||
|
const { getByTestId } = renderModal({
|
||||||
|
props: {
|
||||||
|
modalName: FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
|
data: {
|
||||||
|
nodeName: 'Test Node',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Modal: ModalStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pinia,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('execute-workflow-button'));
|
||||||
|
|
||||||
|
expect(parameterOverridesStore.addParameterOverrides).toHaveBeenCalledWith(
|
||||||
|
'test-workflow',
|
||||||
|
'id1',
|
||||||
|
{
|
||||||
|
testBoolean: true,
|
||||||
|
testParam: 'override',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears parameter overrides when modal is executed', async () => {
|
||||||
|
const { getByTestId } = renderModal({
|
||||||
|
props: {
|
||||||
|
modalName: FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
|
data: {
|
||||||
|
nodeName: 'Test Node',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Modal: ModalStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pinia,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('execute-workflow-button'));
|
||||||
|
|
||||||
|
expect(parameterOverridesStore.clearParameterOverrides).toHaveBeenCalledWith(
|
||||||
|
'test-workflow',
|
||||||
|
'id1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds substitutes for parameters when executed', async () => {
|
||||||
|
const { getByTestId } = renderModal({
|
||||||
|
props: {
|
||||||
|
modalName: FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
|
data: {
|
||||||
|
nodeName: 'Test Node',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Modal: ModalStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pinia,
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputs = getByTestId('from-ai-parameters-modal-inputs');
|
||||||
|
await userEvent.click(inputs.querySelector('input[value="testBoolean"]') as Element);
|
||||||
|
await userEvent.clear(inputs.querySelector('input[name="testParam"]') as Element);
|
||||||
|
await userEvent.type(inputs.querySelector('input[name="testParam"]') as Element, 'given value');
|
||||||
|
await userEvent.click(getByTestId('execute-workflow-button'));
|
||||||
|
|
||||||
|
expect(parameterOverridesStore.addParameterOverrides).toHaveBeenCalledWith(
|
||||||
|
'test-workflow',
|
||||||
|
'id1',
|
||||||
|
{
|
||||||
|
testBoolean: false,
|
||||||
|
testParam: 'given value',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
|
import { FROM_AI_PARAMETERS_MODAL_KEY } from '@/constants';
|
||||||
|
import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { createEventBus } from '@n8n/utils/event-bus';
|
||||||
|
import {
|
||||||
|
type FromAIArgument,
|
||||||
|
NodeConnectionTypes,
|
||||||
|
traverseNodeParametersWithParamNames,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { type IFormInput } from '@n8n/design-system';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
|
||||||
|
type Value = string | number | boolean | null | undefined;
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modalName: string;
|
||||||
|
data: {
|
||||||
|
nodeName: string | undefined;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const inputs = ref<{ getValues: () => Record<string, Value> }>();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
const ndvStore = useNDVStore();
|
||||||
|
const modalBus = createEventBus();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
|
const parameterOverridesStore = useParameterOverridesStore();
|
||||||
|
|
||||||
|
const node = computed(() =>
|
||||||
|
props.data.nodeName ? workflowsStore.getNodeByName(props.data.nodeName) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parentNode = computed(() => {
|
||||||
|
if (!node.value) return undefined;
|
||||||
|
const workflow = workflowsStore.getCurrentWorkflow();
|
||||||
|
const parentNodes = workflow.getChildNodes(node.value.name, 'ALL', 1);
|
||||||
|
if (parentNodes.length === 0) return undefined;
|
||||||
|
return workflowsStore.getNodeByName(parentNodes[0])?.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeRunData = computed(() => {
|
||||||
|
if (!node.value) return undefined;
|
||||||
|
|
||||||
|
const workflowExecutionData = workflowsStore.getWorkflowExecution;
|
||||||
|
const lastRunData = workflowExecutionData?.data?.resultData.runData[node.value?.name];
|
||||||
|
if (!lastRunData) return undefined;
|
||||||
|
return lastRunData[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapTypes: {
|
||||||
|
[key: string]: {
|
||||||
|
inputType: 'text' | 'number' | 'checkbox';
|
||||||
|
defaultValue: string | number | boolean | null | undefined;
|
||||||
|
};
|
||||||
|
} = {
|
||||||
|
['string']: {
|
||||||
|
inputType: 'text',
|
||||||
|
defaultValue: '',
|
||||||
|
},
|
||||||
|
['boolean']: {
|
||||||
|
inputType: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
['number']: {
|
||||||
|
inputType: 'number',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
['json']: {
|
||||||
|
inputType: 'text',
|
||||||
|
defaultValue: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const parameters = computed(() => {
|
||||||
|
if (!node.value) return [];
|
||||||
|
|
||||||
|
const result: IFormInput[] = [];
|
||||||
|
const params = node.value.parameters;
|
||||||
|
const collectedArgs: Map<string, FromAIArgument> = new Map();
|
||||||
|
traverseNodeParametersWithParamNames(params, collectedArgs);
|
||||||
|
const inputOverrides =
|
||||||
|
nodeRunData.value?.inputOverride?.[NodeConnectionTypes.AiTool]?.[0]?.[0].json;
|
||||||
|
|
||||||
|
collectedArgs.forEach((value: FromAIArgument, paramName: string) => {
|
||||||
|
const type = value.type ?? 'string';
|
||||||
|
const initialValue = inputOverrides?.[value.key]
|
||||||
|
? inputOverrides[value.key]
|
||||||
|
: (parameterOverridesStore.getParameterOverride(
|
||||||
|
workflowsStore.workflowId,
|
||||||
|
node.value!.id,
|
||||||
|
paramName,
|
||||||
|
) ?? mapTypes[type]?.defaultValue);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
name: paramName,
|
||||||
|
initialValue: initialValue as string | number | boolean | null | undefined,
|
||||||
|
properties: {
|
||||||
|
label: value.key,
|
||||||
|
type: mapTypes[type].inputType,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
modalBus.emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onExecute = async () => {
|
||||||
|
if (!node.value) return;
|
||||||
|
const inputValues = inputs.value!.getValues();
|
||||||
|
|
||||||
|
parameterOverridesStore.clearParameterOverrides(workflowsStore.workflowId, node.value.id);
|
||||||
|
parameterOverridesStore.addParameterOverrides(
|
||||||
|
workflowsStore.workflowId,
|
||||||
|
node.value.id,
|
||||||
|
inputValues,
|
||||||
|
);
|
||||||
|
|
||||||
|
const telemetryPayload = {
|
||||||
|
node_type: node.value.type,
|
||||||
|
workflow_id: workflowsStore.workflowId,
|
||||||
|
source: 'from-ai-parameters-modal',
|
||||||
|
push_ref: ndvStore.pushRef,
|
||||||
|
};
|
||||||
|
|
||||||
|
telemetry.track('User clicked execute node button in modal', telemetryPayload);
|
||||||
|
|
||||||
|
await runWorkflow({
|
||||||
|
destinationNode: node.value.name,
|
||||||
|
source: 'RunData.TestExecuteModal',
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
max-width="540px"
|
||||||
|
:title="
|
||||||
|
i18n.baseText('fromAiParametersModal.title', { interpolate: { nodeName: node?.name || '' } })
|
||||||
|
"
|
||||||
|
:event-bus="modalBus"
|
||||||
|
:name="FROM_AI_PARAMETERS_MODAL_KEY"
|
||||||
|
:center="true"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<el-col>
|
||||||
|
<el-row :class="$style.row">
|
||||||
|
<n8n-text data-testid="from-ai-parameters-modal-description">
|
||||||
|
{{
|
||||||
|
i18n.baseText('fromAiParametersModal.description', {
|
||||||
|
interpolate: { parentNodeName: parentNode || '' },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</n8n-text>
|
||||||
|
</el-row>
|
||||||
|
</el-col>
|
||||||
|
<el-col>
|
||||||
|
<el-row :class="$style.row">
|
||||||
|
<N8nFormInputs
|
||||||
|
ref="inputs"
|
||||||
|
:inputs="parameters"
|
||||||
|
:column-view="true"
|
||||||
|
data-test-id="from-ai-parameters-modal-inputs"
|
||||||
|
@submit="onExecute"
|
||||||
|
></N8nFormInputs>
|
||||||
|
</el-row>
|
||||||
|
</el-col>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<el-row justify="end">
|
||||||
|
<el-col :span="5" :offset="19">
|
||||||
|
<n8n-button
|
||||||
|
data-test-id="execute-workflow-button"
|
||||||
|
icon="flask"
|
||||||
|
:label="i18n.baseText('fromAiParametersModal.execute')"
|
||||||
|
@click="onExecute"
|
||||||
|
/>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.row {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
DELETE_FOLDER_MODAL_KEY,
|
DELETE_FOLDER_MODAL_KEY,
|
||||||
MOVE_FOLDER_MODAL_KEY,
|
MOVE_FOLDER_MODAL_KEY,
|
||||||
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
|
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
|
||||||
|
FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
|
||||||
import AboutModal from '@/components/AboutModal.vue';
|
import AboutModal from '@/components/AboutModal.vue';
|
||||||
@@ -73,6 +74,7 @@ import NewAssistantSessionModal from '@/components/AskAssistant/Chat/NewAssistan
|
|||||||
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
||||||
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
|
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
|
||||||
import WorkflowActivationConflictingWebhookModal from '@/components/WorkflowActivationConflictingWebhookModal.vue';
|
import WorkflowActivationConflictingWebhookModal from '@/components/WorkflowActivationConflictingWebhookModal.vue';
|
||||||
|
import FromAiParametersModal from '@/components/FromAiParametersModal.vue';
|
||||||
import type { EventBus } from '@n8n/utils/event-bus';
|
import type { EventBus } from '@n8n/utils/event-bus';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -302,5 +304,11 @@ import type { EventBus } from '@n8n/utils/event-bus';
|
|||||||
<WorkflowActivationConflictingWebhookModal :data="data" :modal-name="modalName" />
|
<WorkflowActivationConflictingWebhookModal :data="data" :modal-name="modalName" />
|
||||||
</template>
|
</template>
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
|
<ModalRoot :name="FROM_AI_PARAMETERS_MODAL_KEY">
|
||||||
|
<template #default="{ modalName, data }">
|
||||||
|
<FromAiParametersModal :modal-name="modalName" :data="data" />
|
||||||
|
</template>
|
||||||
|
</ModalRoot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
MODAL_CONFIRM,
|
MODAL_CONFIRM,
|
||||||
FORM_TRIGGER_NODE_TYPE,
|
FORM_TRIGGER_NODE_TYPE,
|
||||||
CHAT_TRIGGER_NODE_TYPE,
|
CHAT_TRIGGER_NODE_TYPE,
|
||||||
|
FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import {
|
import {
|
||||||
AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT,
|
AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT,
|
||||||
@@ -27,6 +28,7 @@ import { useI18n } from '@/composables/useI18n';
|
|||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { type IUpdateInformation } from '@/Interface';
|
import { type IUpdateInformation } from '@/Interface';
|
||||||
import { generateCodeForAiTransform } from '@/components/ButtonParameter/utils';
|
import { generateCodeForAiTransform } from '@/components/ButtonParameter/utils';
|
||||||
|
import { hasFromAiExpressions } from '@/utils/nodes/nodeTransforms';
|
||||||
|
|
||||||
const NODE_TEST_STEP_POPUP_COUNT_KEY = 'N8N_NODE_TEST_STEP_POPUP_COUNT';
|
const NODE_TEST_STEP_POPUP_COUNT_KEY = 'N8N_NODE_TEST_STEP_POPUP_COUNT';
|
||||||
const MAX_POPUP_COUNT = 10;
|
const MAX_POPUP_COUNT = 10;
|
||||||
@@ -341,22 +343,31 @@ async function onClick() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!pinnedData.hasData.value || shouldUnpinAndExecute) {
|
if (!pinnedData.hasData.value || shouldUnpinAndExecute) {
|
||||||
const telemetryPayload = {
|
if (node.value && hasFromAiExpressions(node.value)) {
|
||||||
node_type: nodeType.value ? nodeType.value.name : null,
|
uiStore.openModalWithData({
|
||||||
workflow_id: workflowsStore.workflowId,
|
name: FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
source: props.telemetrySource,
|
data: {
|
||||||
push_ref: ndvStore.pushRef,
|
nodeName: props.nodeName,
|
||||||
};
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const telemetryPayload = {
|
||||||
|
node_type: nodeType.value ? nodeType.value.name : null,
|
||||||
|
workflow_id: workflowsStore.workflowId,
|
||||||
|
source: props.telemetrySource,
|
||||||
|
push_ref: ndvStore.pushRef,
|
||||||
|
};
|
||||||
|
|
||||||
telemetry.track('User clicked execute node button', telemetryPayload);
|
telemetry.track('User clicked execute node button', telemetryPayload);
|
||||||
await externalHooks.run('nodeExecuteButton.onClick', telemetryPayload);
|
await externalHooks.run('nodeExecuteButton.onClick', telemetryPayload);
|
||||||
|
|
||||||
await runWorkflow({
|
await runWorkflow({
|
||||||
destinationNode: props.nodeName,
|
destinationNode: props.nodeName,
|
||||||
source: 'RunData.ExecuteNodeButton',
|
source: 'RunData.ExecuteNodeButton',
|
||||||
});
|
});
|
||||||
|
|
||||||
emit('execute');
|
emit('execute');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ const node = computed(() => ndvStore.activeNode);
|
|||||||
|
|
||||||
const isTriggerNode = computed(() => !!node.value && nodeTypesStore.isTriggerNode(node.value.type));
|
const isTriggerNode = computed(() => !!node.value && nodeTypesStore.isTriggerNode(node.value.type));
|
||||||
|
|
||||||
|
const isNodesAsToolNode = computed(
|
||||||
|
() => !!node.value && nodeTypesStore.isNodesAsToolNode(node.value.type),
|
||||||
|
);
|
||||||
|
|
||||||
const isExecutable = computed(() => {
|
const isExecutable = computed(() => {
|
||||||
if (props.nodeType && node.value) {
|
if (props.nodeType && node.value) {
|
||||||
const workflowNode = currentWorkflowInstance.value.getNode(node.value.name);
|
const workflowNode = currentWorkflowInstance.value.getNode(node.value.name);
|
||||||
@@ -140,7 +144,11 @@ const isExecutable = computed(() => {
|
|||||||
);
|
);
|
||||||
const inputNames = NodeHelpers.getConnectionTypes(inputs);
|
const inputNames = NodeHelpers.getConnectionTypes(inputs);
|
||||||
|
|
||||||
if (!inputNames.includes(NodeConnectionTypes.Main) && !isTriggerNode.value) {
|
if (
|
||||||
|
!inputNames.includes(NodeConnectionTypes.Main) &&
|
||||||
|
!isNodesAsToolNode.value &&
|
||||||
|
!isTriggerNode.value
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,21 @@ import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeTool
|
|||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data';
|
import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data';
|
||||||
import { CanvasNodeRenderType } from '@/types';
|
import { CanvasNodeRenderType } from '@/types';
|
||||||
|
import { createPinia, setActivePinia, type Pinia } from 'pinia';
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(CanvasNodeToolbar);
|
const renderComponent = createComponentRenderer(CanvasNodeToolbar);
|
||||||
|
|
||||||
describe('CanvasNodeToolbar', () => {
|
describe('CanvasNodeToolbar', () => {
|
||||||
|
let pinia: Pinia;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pinia = createPinia();
|
||||||
|
setActivePinia(pinia);
|
||||||
|
});
|
||||||
|
|
||||||
it('should render execute node button when renderType is not configuration', async () => {
|
it('should render execute node button when renderType is not configuration', async () => {
|
||||||
const { getByTestId } = renderComponent({
|
const { getByTestId } = renderComponent({
|
||||||
|
pinia,
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
...createCanvasNodeProvide(),
|
...createCanvasNodeProvide(),
|
||||||
@@ -23,6 +32,7 @@ describe('CanvasNodeToolbar', () => {
|
|||||||
|
|
||||||
it('should render disabled execute node button when canvas is executing', () => {
|
it('should render disabled execute node button when canvas is executing', () => {
|
||||||
const { getByTestId } = renderComponent({
|
const { getByTestId } = renderComponent({
|
||||||
|
pinia,
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
...createCanvasNodeProvide(),
|
...createCanvasNodeProvide(),
|
||||||
@@ -38,6 +48,7 @@ describe('CanvasNodeToolbar', () => {
|
|||||||
|
|
||||||
it('should render disabled execute node button when node is deactivated', async () => {
|
it('should render disabled execute node button when node is deactivated', async () => {
|
||||||
const { getByTestId, getByRole } = renderComponent({
|
const { getByTestId, getByRole } = renderComponent({
|
||||||
|
pinia,
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
...createCanvasNodeProvide({
|
...createCanvasNodeProvide({
|
||||||
@@ -96,6 +107,7 @@ describe('CanvasNodeToolbar', () => {
|
|||||||
|
|
||||||
it('should emit "toggle" when disable node button is clicked', async () => {
|
it('should emit "toggle" when disable node button is clicked', async () => {
|
||||||
const { getByTestId, emitted } = renderComponent({
|
const { getByTestId, emitted } = renderComponent({
|
||||||
|
pinia,
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
...createCanvasNodeProvide(),
|
...createCanvasNodeProvide(),
|
||||||
@@ -111,6 +123,7 @@ describe('CanvasNodeToolbar', () => {
|
|||||||
|
|
||||||
it('should emit "delete" when delete node button is clicked', async () => {
|
it('should emit "delete" when delete node button is clicked', async () => {
|
||||||
const { getByTestId, emitted } = renderComponent({
|
const { getByTestId, emitted } = renderComponent({
|
||||||
|
pinia,
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
...createCanvasNodeProvide(),
|
...createCanvasNodeProvide(),
|
||||||
@@ -126,6 +139,7 @@ describe('CanvasNodeToolbar', () => {
|
|||||||
|
|
||||||
it('should emit "open:contextmenu" when overflow node button is clicked', async () => {
|
it('should emit "open:contextmenu" when overflow node button is clicked', async () => {
|
||||||
const { getByTestId, emitted } = renderComponent({
|
const { getByTestId, emitted } = renderComponent({
|
||||||
|
pinia,
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
...createCanvasNodeProvide(),
|
...createCanvasNodeProvide(),
|
||||||
@@ -141,6 +155,7 @@ describe('CanvasNodeToolbar', () => {
|
|||||||
|
|
||||||
it('should emit "update" when sticky note color is changed', async () => {
|
it('should emit "update" when sticky note color is changed', async () => {
|
||||||
const { getAllByTestId, getByTestId, emitted } = renderComponent({
|
const { getAllByTestId, getByTestId, emitted } = renderComponent({
|
||||||
|
pinia,
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
...createCanvasNodeProvide({
|
...createCanvasNodeProvide({
|
||||||
@@ -164,6 +179,7 @@ describe('CanvasNodeToolbar', () => {
|
|||||||
|
|
||||||
it('should have "forceVisible" class when hovered', async () => {
|
it('should have "forceVisible" class when hovered', async () => {
|
||||||
const { getByTestId } = renderComponent({
|
const { getByTestId } = renderComponent({
|
||||||
|
pinia,
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
...createCanvasNodeProvide(),
|
...createCanvasNodeProvide(),
|
||||||
@@ -181,6 +197,7 @@ describe('CanvasNodeToolbar', () => {
|
|||||||
|
|
||||||
it('should have "forceVisible" class when sticky color picker is visible', async () => {
|
it('should have "forceVisible" class when sticky color picker is visible', async () => {
|
||||||
const { getByTestId } = renderComponent({
|
const { getByTestId } = renderComponent({
|
||||||
|
pinia,
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
...createCanvasNodeProvide({
|
...createCanvasNodeProvide({
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useI18n } from '@/composables/useI18n';
|
|||||||
import { useCanvasNode } from '@/composables/useCanvasNode';
|
import { useCanvasNode } from '@/composables/useCanvasNode';
|
||||||
import { CanvasNodeRenderType } from '@/types';
|
import { CanvasNodeRenderType } from '@/types';
|
||||||
import { useCanvas } from '@/composables/useCanvas';
|
import { useCanvas } from '@/composables/useCanvas';
|
||||||
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
delete: [];
|
delete: [];
|
||||||
@@ -21,7 +23,15 @@ const $style = useCssModule();
|
|||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
const { isExecuting } = useCanvas();
|
const { isExecuting } = useCanvas();
|
||||||
const { isDisabled, render } = useCanvasNode();
|
const { isDisabled, render, name } = useCanvasNode();
|
||||||
|
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
|
|
||||||
|
const node = computed(() => !!name.value && workflowsStore.getNodeByName(name.value));
|
||||||
|
const isNodesAsToolNode = computed(
|
||||||
|
() => !!node.value && nodeTypesStore.isNodesAsToolNode(node.value.type),
|
||||||
|
);
|
||||||
|
|
||||||
const nodeDisabledTitle = computed(() => {
|
const nodeDisabledTitle = computed(() => {
|
||||||
return isDisabled.value ? i18n.baseText('node.enable') : i18n.baseText('node.disable');
|
return isDisabled.value ? i18n.baseText('node.enable') : i18n.baseText('node.disable');
|
||||||
@@ -41,7 +51,7 @@ const isExecuteNodeVisible = computed(() => {
|
|||||||
!props.readOnly &&
|
!props.readOnly &&
|
||||||
render.value.type === CanvasNodeRenderType.Default &&
|
render.value.type === CanvasNodeRenderType.Default &&
|
||||||
'configuration' in render.value.options &&
|
'configuration' in render.value.options &&
|
||||||
!render.value.options.configuration
|
(!render.value.options.configuration || isNodesAsToolNode.value)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
IExecuteData,
|
IExecuteData,
|
||||||
ITaskData,
|
ITaskData,
|
||||||
INodeConnections,
|
INodeConnections,
|
||||||
|
INode,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
@@ -24,6 +25,7 @@ import { useSettingsStore } from '@/stores/settings.store';
|
|||||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
|
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
|
||||||
import { waitFor } from '@testing-library/vue';
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
|
||||||
|
|
||||||
vi.mock('@/stores/workflows.store', () => {
|
vi.mock('@/stores/workflows.store', () => {
|
||||||
const storeState: Partial<ReturnType<typeof useWorkflowsStore>> & {
|
const storeState: Partial<ReturnType<typeof useWorkflowsStore>> & {
|
||||||
@@ -40,7 +42,11 @@ vi.mock('@/stores/workflows.store', () => {
|
|||||||
nodesIssuesExist: false,
|
nodesIssuesExist: false,
|
||||||
executionWaitingForWebhook: false,
|
executionWaitingForWebhook: false,
|
||||||
getCurrentWorkflow: vi.fn().mockReturnValue({ id: '123' }),
|
getCurrentWorkflow: vi.fn().mockReturnValue({ id: '123' }),
|
||||||
getNodeByName: vi.fn(),
|
getNodeByName: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((name) =>
|
||||||
|
name === 'Test node' ? { name: 'Test node', id: 'Test id' } : undefined,
|
||||||
|
),
|
||||||
getExecution: vi.fn(),
|
getExecution: vi.fn(),
|
||||||
checkIfNodeHasChatParent: vi.fn(),
|
checkIfNodeHasChatParent: vi.fn(),
|
||||||
getParametersLastUpdate: vi.fn(),
|
getParametersLastUpdate: vi.fn(),
|
||||||
@@ -59,6 +65,16 @@ vi.mock('@/stores/workflows.store', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/parameterOverrides.store', () => {
|
||||||
|
const storeState: Partial<ReturnType<typeof useParameterOverridesStore>> & {} = {
|
||||||
|
parameterOverrides: {},
|
||||||
|
substituteParameters: vi.fn(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
useParameterOverridesStore: vi.fn().mockReturnValue(storeState),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('@/stores/pushConnection.store', () => ({
|
vi.mock('@/stores/pushConnection.store', () => ({
|
||||||
usePushConnectionStore: vi.fn().mockReturnValue({
|
usePushConnectionStore: vi.fn().mockReturnValue({
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
@@ -122,6 +138,7 @@ describe('useRunWorkflow({ router })', () => {
|
|||||||
let router: ReturnType<typeof useRouter>;
|
let router: ReturnType<typeof useRouter>;
|
||||||
let workflowHelpers: ReturnType<typeof useWorkflowHelpers>;
|
let workflowHelpers: ReturnType<typeof useWorkflowHelpers>;
|
||||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||||
|
let parameterOverridesStore: ReturnType<typeof useParameterOverridesStore>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const pinia = createTestingPinia({ stubActions: false });
|
const pinia = createTestingPinia({ stubActions: false });
|
||||||
@@ -132,6 +149,7 @@ describe('useRunWorkflow({ router })', () => {
|
|||||||
uiStore = useUIStore();
|
uiStore = useUIStore();
|
||||||
workflowsStore = useWorkflowsStore();
|
workflowsStore = useWorkflowsStore();
|
||||||
settingsStore = useSettingsStore();
|
settingsStore = useSettingsStore();
|
||||||
|
parameterOverridesStore = useParameterOverridesStore();
|
||||||
|
|
||||||
router = useRouter();
|
router = useRouter();
|
||||||
workflowHelpers = useWorkflowHelpers({ router });
|
workflowHelpers = useWorkflowHelpers({ router });
|
||||||
@@ -494,6 +512,69 @@ describe('useRunWorkflow({ router })', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does substituteParameters on partial execution if `partialExecutionVersion` is set to 2', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const mockExecutionResponse = { executionId: '123' };
|
||||||
|
const mockRunData = { nodeName: [] };
|
||||||
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
|
const dataCaptor = captor();
|
||||||
|
|
||||||
|
const workflow = mock<Workflow>({
|
||||||
|
name: 'Test Workflow',
|
||||||
|
id: 'WorkflowId',
|
||||||
|
nodes: {
|
||||||
|
'Test node': {
|
||||||
|
id: 'Test id',
|
||||||
|
name: 'Test node',
|
||||||
|
parameters: {
|
||||||
|
param: '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflowData = {
|
||||||
|
id: 'workflowId',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'Test id',
|
||||||
|
name: 'Test node',
|
||||||
|
parameters: {
|
||||||
|
param: '0',
|
||||||
|
},
|
||||||
|
position: [0, 0],
|
||||||
|
type: 'n8n-nodes-base.test',
|
||||||
|
typeVersion: 1,
|
||||||
|
} as INode,
|
||||||
|
],
|
||||||
|
connections: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
workflow.getParentNodes.mockReturnValue([]);
|
||||||
|
|
||||||
|
vi.mocked(settingsStore).partialExecutionVersion = 2;
|
||||||
|
vi.mocked(pushConnectionStore).isConnected = true;
|
||||||
|
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
|
||||||
|
vi.mocked(workflowsStore).nodesIssuesExist = false;
|
||||||
|
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow);
|
||||||
|
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue(workflowData);
|
||||||
|
vi.mocked(workflowsStore).getWorkflowRunData = mockRunData;
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const result = await runWorkflow({ destinationNode: 'Test node' });
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(parameterOverridesStore.substituteParameters).toHaveBeenCalledWith(
|
||||||
|
'WorkflowId',
|
||||||
|
'Test id',
|
||||||
|
{ param: '0' },
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockExecutionResponse);
|
||||||
|
expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledTimes(1);
|
||||||
|
expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledWith(dataCaptor);
|
||||||
|
expect(dataCaptor.value).toMatchObject({ data: { resultData: { runData: mockRunData } } });
|
||||||
|
});
|
||||||
|
|
||||||
it('retains the original run data if `partialExecutionVersion` is set to 2', async () => {
|
it('retains the original run data if `partialExecutionVersion` is set to 2', async () => {
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
const mockExecutionResponse = { executionId: '123' };
|
const mockExecutionResponse = { executionId: '123' };
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { useTelemetry } from './useTelemetry';
|
|||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
|
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
|
||||||
|
import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
|
||||||
|
|
||||||
export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof useRouter> }) {
|
export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof useRouter> }) {
|
||||||
const nodeHelpers = useNodeHelpers();
|
const nodeHelpers = useNodeHelpers();
|
||||||
@@ -51,6 +52,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
|||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const externalHooks = useExternalHooks();
|
const externalHooks = useExternalHooks();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
const parameterOverridesStore = useParameterOverridesStore();
|
||||||
|
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const pushConnectionStore = usePushConnectionStore();
|
const pushConnectionStore = usePushConnectionStore();
|
||||||
@@ -278,6 +280,17 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
|||||||
|
|
||||||
if ('destinationNode' in options) {
|
if ('destinationNode' in options) {
|
||||||
startRunData.destinationNode = options.destinationNode;
|
startRunData.destinationNode = options.destinationNode;
|
||||||
|
const nodeId = workflowsStore.getNodeByName(options.destinationNode as string)?.id;
|
||||||
|
if (nodeId && version === 2) {
|
||||||
|
const node = workflowData.nodes.find((nodeData) => nodeData.id === nodeId);
|
||||||
|
if (node?.parameters) {
|
||||||
|
node.parameters = parameterOverridesStore.substituteParameters(
|
||||||
|
workflow.id,
|
||||||
|
nodeId,
|
||||||
|
node?.parameters,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startRunData.runData) {
|
if (startRunData.runData) {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const DELETE_FOLDER_MODAL_KEY = 'deleteFolder';
|
|||||||
export const MOVE_FOLDER_MODAL_KEY = 'moveFolder';
|
export const MOVE_FOLDER_MODAL_KEY = 'moveFolder';
|
||||||
export const WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY =
|
export const WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY =
|
||||||
'workflowActivationConflictingWebhook';
|
'workflowActivationConflictingWebhook';
|
||||||
|
export const FROM_AI_PARAMETERS_MODAL_KEY = 'fromAiParameters';
|
||||||
|
|
||||||
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
||||||
UNINSTALL: 'uninstall',
|
UNINSTALL: 'uninstall',
|
||||||
|
|||||||
@@ -834,6 +834,10 @@
|
|||||||
"executionsList.debug.paywall.content": "Debug in Editor allows you to debug a previous execution with the actual data pinned, right in your editor.",
|
"executionsList.debug.paywall.content": "Debug in Editor allows you to debug a previous execution with the actual data pinned, right in your editor.",
|
||||||
"executionsList.debug.paywall.link.text": "Read more in the docs",
|
"executionsList.debug.paywall.link.text": "Read more in the docs",
|
||||||
"executionsList.debug.paywall.link.url": "https://docs.n8n.io/workflows/executions/debug/",
|
"executionsList.debug.paywall.link.url": "https://docs.n8n.io/workflows/executions/debug/",
|
||||||
|
"fromAiParametersModal.title": "Test {nodeName}",
|
||||||
|
"fromAiParametersModal.execute": "Test step",
|
||||||
|
"fromAiParametersModal.description": "Provide the data that would normally come from the \"{parentNodeName}\" node",
|
||||||
|
"fromAiParametersModal.cancel": "Cancel",
|
||||||
"workerList.pageTitle": "Workers",
|
"workerList.pageTitle": "Workers",
|
||||||
"workerList.empty": "No workers are responding or available",
|
"workerList.empty": "No workers are responding or available",
|
||||||
"workerList.item.lastUpdated": "Last updated",
|
"workerList.item.lastUpdated": "Last updated",
|
||||||
|
|||||||
@@ -122,6 +122,17 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isNodesAsToolNode = computed(() => {
|
||||||
|
return (nodeTypeName: string) => {
|
||||||
|
const nodeType = getNodeType.value(nodeTypeName);
|
||||||
|
return !!(
|
||||||
|
nodeType &&
|
||||||
|
nodeType.outputs.includes(NodeConnectionTypes.AiTool) &&
|
||||||
|
nodeType.usableAsTool
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const isCoreNodeType = computed(() => {
|
const isCoreNodeType = computed(() => {
|
||||||
return (nodeType: INodeTypeDescription) => {
|
return (nodeType: INodeTypeDescription) => {
|
||||||
return nodeType.codex?.categories?.includes('Core Nodes');
|
return nodeType.codex?.categories?.includes('Core Nodes');
|
||||||
@@ -328,6 +339,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
|||||||
getCredentialOnlyNodeType,
|
getCredentialOnlyNodeType,
|
||||||
isConfigNode,
|
isConfigNode,
|
||||||
isTriggerNode,
|
isTriggerNode,
|
||||||
|
isNodesAsToolNode,
|
||||||
isCoreNodeType,
|
isCoreNodeType,
|
||||||
visibleNodeTypes,
|
visibleNodeTypes,
|
||||||
nativelyNumberSuffixedDefaults,
|
nativelyNumberSuffixedDefaults,
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { type INodeParameters, type NodeParameterValueType } from 'n8n-workflow';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
interface IParameterOverridesStoreState {
|
||||||
|
[workflowId: string]: {
|
||||||
|
[nodeName: string]: INodeParameters;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'n8n-parameter-overrides';
|
||||||
|
|
||||||
|
export const useParameterOverridesStore = defineStore('parameterOverrides', () => {
|
||||||
|
// State
|
||||||
|
const parameterOverrides = ref<IParameterOverridesStoreState>(loadFromLocalStorage());
|
||||||
|
|
||||||
|
// Load initial state from localStorage
|
||||||
|
function loadFromLocalStorage(): IParameterOverridesStoreState {
|
||||||
|
try {
|
||||||
|
const storedData = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return storedData ? JSON.parse(storedData) : {};
|
||||||
|
} catch (error) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save state to localStorage whenever it changes
|
||||||
|
watch(
|
||||||
|
parameterOverrides,
|
||||||
|
(newValue) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(newValue));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save parameter overrides to localStorage:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper function to ensure workflow and node entries exist
|
||||||
|
const ensureWorkflowAndNodeExist = (workflowId: string, nodeId: string): void => {
|
||||||
|
if (!parameterOverrides.value[workflowId]) {
|
||||||
|
parameterOverrides.value[workflowId] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parameterOverrides.value[workflowId][nodeId]) {
|
||||||
|
parameterOverrides.value[workflowId][nodeId] = {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const getParameterOverrides = (workflowId: string, nodeId: string): INodeParameters => {
|
||||||
|
return parameterOverrides.value[workflowId]?.[nodeId] || {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getParameterOverride = (
|
||||||
|
workflowId: string,
|
||||||
|
nodeId: string,
|
||||||
|
paramName: string,
|
||||||
|
): NodeParameterValueType | undefined => {
|
||||||
|
return parameterOverrides.value[workflowId]?.[nodeId]?.[paramName];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const addParameterOverride = (
|
||||||
|
workflowId: string,
|
||||||
|
nodeId: string,
|
||||||
|
paramName: string,
|
||||||
|
paramValues: NodeParameterValueType,
|
||||||
|
): INodeParameters => {
|
||||||
|
ensureWorkflowAndNodeExist(workflowId, nodeId);
|
||||||
|
|
||||||
|
parameterOverrides.value[workflowId][nodeId] = {
|
||||||
|
...parameterOverrides.value[workflowId][nodeId],
|
||||||
|
[paramName]: paramValues,
|
||||||
|
};
|
||||||
|
|
||||||
|
return parameterOverrides.value[workflowId][nodeId];
|
||||||
|
};
|
||||||
|
|
||||||
|
const addParameterOverrides = (
|
||||||
|
workflowId: string,
|
||||||
|
nodeId: string,
|
||||||
|
params: INodeParameters,
|
||||||
|
): void => {
|
||||||
|
ensureWorkflowAndNodeExist(workflowId, nodeId);
|
||||||
|
|
||||||
|
parameterOverrides.value[workflowId][nodeId] = {
|
||||||
|
...parameterOverrides.value[workflowId][nodeId],
|
||||||
|
...params,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearParameterOverrides = (workflowId: string, nodeId: string): void => {
|
||||||
|
if (parameterOverrides.value[workflowId]) {
|
||||||
|
parameterOverrides.value[workflowId][nodeId] = {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAllParameterOverrides = (workflowId?: string): void => {
|
||||||
|
if (workflowId) {
|
||||||
|
// Clear overrides for a specific workflow
|
||||||
|
parameterOverrides.value[workflowId] = {};
|
||||||
|
} else {
|
||||||
|
// Clear all overrides
|
||||||
|
parameterOverrides.value = {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function parsePath(path: string): string[] {
|
||||||
|
return path.split('.').reduce((acc: string[], part) => {
|
||||||
|
if (part.includes('[')) {
|
||||||
|
const [arrayName, index] = part.split('[');
|
||||||
|
if (arrayName) acc.push(arrayName);
|
||||||
|
if (index) acc.push(index.replace(']', ''));
|
||||||
|
} else {
|
||||||
|
acc.push(part);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverrideObject(path: string[], value: NodeParameterValueType): INodeParameters {
|
||||||
|
const result: INodeParameters = {};
|
||||||
|
let current = result;
|
||||||
|
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
const part = path[i];
|
||||||
|
const nextPart = path[i + 1];
|
||||||
|
const isArrayIndex = nextPart && !isNaN(Number(nextPart));
|
||||||
|
|
||||||
|
if (isArrayIndex) {
|
||||||
|
if (!current[part]) {
|
||||||
|
current[part] = [];
|
||||||
|
}
|
||||||
|
while ((current[part] as NodeParameterValueType[]).length <= Number(nextPart)) {
|
||||||
|
(current[part] as NodeParameterValueType[]).push({});
|
||||||
|
}
|
||||||
|
} else if (!current[part]) {
|
||||||
|
current[part] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current[part] as INodeParameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
current[path[path.length - 1]] = value;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to deep merge objects
|
||||||
|
function deepMerge(target: INodeParameters, source: INodeParameters): INodeParameters {
|
||||||
|
const result = { ...target };
|
||||||
|
|
||||||
|
for (const key in source) {
|
||||||
|
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||||
|
// Recursively merge nested objects
|
||||||
|
result[key] = deepMerge(
|
||||||
|
(result[key] as INodeParameters) || {},
|
||||||
|
source[key] as INodeParameters,
|
||||||
|
);
|
||||||
|
} else if (Array.isArray(source[key])) {
|
||||||
|
// For arrays, merge by index
|
||||||
|
if (Array.isArray(result[key])) {
|
||||||
|
const targetArray = result[key] as NodeParameterValueType[];
|
||||||
|
const sourceArray = source[key] as NodeParameterValueType[];
|
||||||
|
|
||||||
|
// Ensure target array has enough elements
|
||||||
|
while (targetArray.length < sourceArray.length) {
|
||||||
|
targetArray.push({});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge each array item
|
||||||
|
sourceArray.forEach((item, index) => {
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
targetArray[index] = deepMerge(
|
||||||
|
(targetArray[index] as INodeParameters) || {},
|
||||||
|
item as INodeParameters,
|
||||||
|
) as NodeParameterValueType;
|
||||||
|
} else {
|
||||||
|
targetArray[index] = item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result[key] = source[key];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For primitive values, use source value
|
||||||
|
result[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const substituteParameters = (
|
||||||
|
workflowId: string,
|
||||||
|
nodeId: string,
|
||||||
|
nodeParameters: INodeParameters,
|
||||||
|
): INodeParameters => {
|
||||||
|
if (!nodeParameters) return {};
|
||||||
|
|
||||||
|
const nodeOverrides = parameterOverrides.value[workflowId]?.[nodeId] || {};
|
||||||
|
|
||||||
|
const overrideParams = Object.entries(nodeOverrides).reduce(
|
||||||
|
(acc, [path, value]) => deepMerge(acc, buildOverrideObject(parsePath(path), value)),
|
||||||
|
{} as INodeParameters,
|
||||||
|
);
|
||||||
|
|
||||||
|
return deepMerge(nodeParameters, overrideParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
parameterOverrides,
|
||||||
|
getParameterOverrides,
|
||||||
|
getParameterOverride,
|
||||||
|
addParameterOverride,
|
||||||
|
addParameterOverrides,
|
||||||
|
clearParameterOverrides,
|
||||||
|
clearAllParameterOverrides,
|
||||||
|
substituteParameters,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { setActivePinia, createPinia } from 'pinia';
|
||||||
|
import { useParameterOverridesStore } from './parameterOverrides.store';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { nextTick } from 'vue';
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = {
|
||||||
|
getItem: vi.fn(),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
value: localStorageMock,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parameterOverrides.store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
localStorageMock.getItem.mockReset();
|
||||||
|
localStorageMock.setItem.mockReset();
|
||||||
|
localStorageMock.clear.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initialization', () => {
|
||||||
|
it('initializes with empty state when localStorage is empty', () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue(null);
|
||||||
|
const store = useParameterOverridesStore();
|
||||||
|
expect(store.parameterOverrides).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes with data from localStorage', () => {
|
||||||
|
const mockData = {
|
||||||
|
'workflow-1': {
|
||||||
|
'node-1': { param1: 'value1' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
||||||
|
const store = useParameterOverridesStore();
|
||||||
|
expect(store.parameterOverrides).toEqual(mockData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles localStorage errors gracefully', () => {
|
||||||
|
localStorageMock.getItem.mockImplementation(() => {
|
||||||
|
throw new Error('Storage error');
|
||||||
|
});
|
||||||
|
const store = useParameterOverridesStore();
|
||||||
|
expect(store.parameterOverrides).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Getters', () => {
|
||||||
|
it('gets parameter overrides for a node', () => {
|
||||||
|
const mockData = {
|
||||||
|
'workflow-1': {
|
||||||
|
'node-1': { param1: 'value1', param2: 'value2' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
||||||
|
const store = useParameterOverridesStore();
|
||||||
|
|
||||||
|
const overrides = store.getParameterOverrides('workflow-1', 'node-1');
|
||||||
|
expect(overrides).toEqual({ param1: 'value1', param2: 'value2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object for non-existent workflow/node', () => {
|
||||||
|
const store = useParameterOverridesStore();
|
||||||
|
|
||||||
|
const overrides = store.getParameterOverrides('non-existent', 'node-1');
|
||||||
|
expect(overrides).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets a specific parameter override', () => {
|
||||||
|
const mockData = {
|
||||||
|
'workflow-1': {
|
||||||
|
'node-1': { param1: 'value1', param2: 'value2' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
||||||
|
const store = useParameterOverridesStore();
|
||||||
|
|
||||||
|
const override = store.getParameterOverride('workflow-1', 'node-1', 'param1');
|
||||||
|
expect(override).toBe('value1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for non-existent parameter', () => {
|
||||||
|
const mockData = {
|
||||||
|
'workflow-1': {
|
||||||
|
'node-1': { param1: 'value1' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
||||||
|
const store = useParameterOverridesStore();
|
||||||
|
|
||||||
|
const override = store.getParameterOverride('workflow-1', 'node-1', 'non-existent');
|
||||||
|
expect(override).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Actions', () => {
|
||||||
|
it('adds a parameter override', () => {
|
||||||
|
const store = useParameterOverridesStore();
|
||||||
|
|
||||||
|
store.addParameterOverride('workflow-1', 'node-1', 'param1', 'value1');
|
||||||
|
|
||||||
|
expect(store.parameterOverrides['workflow-1']['node-1']['param1']).toBe('value1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds multiple parameter overrides', () => {
|
||||||
|
const store = useParameterOverridesStore();
|
||||||
|
|
||||||
|
store.addParameterOverrides('workflow-1', 'node-1', {
|
||||||
|
param1: 'value1',
|
||||||
|
param2: 'value2',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.parameterOverrides['workflow-1']['node-1']).toEqual({
|
||||||
|
param1: 'value1',
|
||||||
|
param2: 'value2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears parameter overrides for a node', () => {
|
||||||
|
const mockData = {
|
||||||
|
'workflow-1': {
|
||||||
|
'node-1': { param1: 'value1', param2: 'value2' },
|
||||||
|
'node-2': { param3: 'value3' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
||||||
|
const store = useParameterOverridesStore();
|
||||||
|
|
||||||
|
store.clearParameterOverrides('workflow-1', 'node-1');
|
||||||
|
|
||||||
|
expect(store.parameterOverrides['workflow-1']['node-1']).toEqual({});
|
||||||
|
expect(store.parameterOverrides['workflow-1']['node-2']).toEqual({ param3: 'value3' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all parameter overrides for a workflow', () => {
|
||||||
|
const mockData = {
|
||||||
|
'workflow-1': {
|
||||||
|
'node-1': { param1: 'value1' },
|
||||||
|
'node-2': { param2: 'value2' },
|
||||||
|
},
|
||||||
|
'workflow-2': {
|
||||||
|
'node-3': { param3: 'value3' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
||||||
|
const store = useParameterOverridesStore();
|
||||||
|
|
||||||
|
store.clearAllParameterOverrides('workflow-1');
|
||||||
|
|
||||||
|
expect(store.parameterOverrides['workflow-1']).toEqual({});
|
||||||
|
expect(store.parameterOverrides['workflow-2']).toEqual({
|
||||||
|
'node-3': { param3: 'value3' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all parameter overrides when no workflowId is provided', () => {
|
||||||
|
const mockData = {
|
||||||
|
'workflow-1': {
|
||||||
|
'node-1': { param1: 'value1' },
|
||||||
|
},
|
||||||
|
'workflow-2': {
|
||||||
|
'node-2': { param2: 'value2' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
||||||
|
const store = useParameterOverridesStore();
|
||||||
|
|
||||||
|
store.clearAllParameterOverrides();
|
||||||
|
|
||||||
|
expect(store.parameterOverrides).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('substituteParameters', () => {
|
||||||
|
it('substitutes parameters in a node', () => {
|
||||||
|
const store = useParameterOverridesStore();
|
||||||
|
|
||||||
|
store.addParameterOverrides('workflow-1', 'id1', {
|
||||||
|
param1: 'override1',
|
||||||
|
'parent.child': 'override2',
|
||||||
|
'parent.array[0].value': 'overrideArray1',
|
||||||
|
'parent.array[1].value': 'overrideArray2',
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeParameters = {
|
||||||
|
param1: 'original1',
|
||||||
|
parent: {
|
||||||
|
child: 'original2',
|
||||||
|
array: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
value: 'original1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name2',
|
||||||
|
value: 'original2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = store.substituteParameters('workflow-1', 'id1', nodeParameters);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
param1: 'override1',
|
||||||
|
parent: {
|
||||||
|
child: 'override2',
|
||||||
|
array: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
value: 'overrideArray1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name2',
|
||||||
|
value: 'overrideArray2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Persistence', () => {
|
||||||
|
it('saves to localStorage when state changes', async () => {
|
||||||
|
const store = useParameterOverridesStore();
|
||||||
|
|
||||||
|
localStorageMock.setItem.mockReset();
|
||||||
|
|
||||||
|
store.addParameterOverride('workflow-1', 'node-1', 'param1', 'value1');
|
||||||
|
|
||||||
|
// Wait for the next tick to allow the watch to execute
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
|
'n8n-parameter-overrides',
|
||||||
|
JSON.stringify({
|
||||||
|
'workflow-1': {
|
||||||
|
'node-1': { param1: 'value1' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle localStorage errors when saving', async () => {
|
||||||
|
const store = useParameterOverridesStore();
|
||||||
|
|
||||||
|
localStorageMock.setItem.mockReset();
|
||||||
|
|
||||||
|
localStorageMock.setItem.mockImplementation(() => {
|
||||||
|
throw new Error('Storage error');
|
||||||
|
});
|
||||||
|
|
||||||
|
store.addParameterOverride('workflow-1', 'node-1', 'param1', 'value1');
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(store.parameterOverrides['workflow-1']['node-1'].param1).toBe('value1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
DELETE_FOLDER_MODAL_KEY,
|
DELETE_FOLDER_MODAL_KEY,
|
||||||
MOVE_FOLDER_MODAL_KEY,
|
MOVE_FOLDER_MODAL_KEY,
|
||||||
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
|
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
|
||||||
|
FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type {
|
import type {
|
||||||
INodeUi,
|
INodeUi,
|
||||||
@@ -192,6 +193,12 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||||||
node: '',
|
node: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
[FROM_AI_PARAMETERS_MODAL_KEY]: {
|
||||||
|
open: false,
|
||||||
|
data: {
|
||||||
|
nodeName: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const modalStack = ref<string[]>([]);
|
const modalStack = ref<string[]>([]);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { INodeUi } from '@/Interface';
|
import type { INodeUi } from '@/Interface';
|
||||||
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
|
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
|
||||||
import type { INodeCredentialDescription } from 'n8n-workflow';
|
import type { INodeCredentialDescription, FromAIArgument } from 'n8n-workflow';
|
||||||
import { NodeHelpers } from 'n8n-workflow';
|
import { NodeHelpers, traverseNodeParameters } from 'n8n-workflow';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the credentials that are displayable for the given node.
|
* Returns the credentials that are displayable for the given node.
|
||||||
@@ -77,3 +77,12 @@ export function doesNodeHaveAllCredentialsFilled(
|
|||||||
|
|
||||||
return requiredCredentials.every((cred) => hasNodeCredentialFilled(node, cred.name));
|
return requiredCredentials.every((cred) => hasNodeCredentialFilled(node, cred.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given node has any fromAi expressions in its parameters.
|
||||||
|
*/
|
||||||
|
export function hasFromAiExpressions(node: Pick<INodeUi, 'parameters'>) {
|
||||||
|
const collectedArgs: FromAIArgument[] = [];
|
||||||
|
traverseNodeParameters(node.parameters, collectedArgs);
|
||||||
|
return collectedArgs.length > 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
CHAT_TRIGGER_NODE_TYPE,
|
CHAT_TRIGGER_NODE_TYPE,
|
||||||
DRAG_EVENT_DATA_KEY,
|
DRAG_EVENT_DATA_KEY,
|
||||||
EnterpriseEditionFeature,
|
EnterpriseEditionFeature,
|
||||||
|
FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
MAIN_HEADER_TABS,
|
MAIN_HEADER_TABS,
|
||||||
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||||
MODAL_CONFIRM,
|
MODAL_CONFIRM,
|
||||||
@@ -117,6 +118,8 @@ import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
|
|||||||
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
|
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
|
||||||
import { useBuilderStore } from '@/stores/builder.store';
|
import { useBuilderStore } from '@/stores/builder.store';
|
||||||
import { useFoldersStore } from '@/stores/folders.store';
|
import { useFoldersStore } from '@/stores/folders.store';
|
||||||
|
import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
|
||||||
|
import { hasFromAiExpressions } from '@/utils/nodes/nodeTransforms';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'NodeView',
|
name: 'NodeView',
|
||||||
@@ -169,6 +172,7 @@ const ndvStore = useNDVStore();
|
|||||||
const templatesStore = useTemplatesStore();
|
const templatesStore = useTemplatesStore();
|
||||||
const builderStore = useBuilderStore();
|
const builderStore = useBuilderStore();
|
||||||
const foldersStore = useFoldersStore();
|
const foldersStore = useFoldersStore();
|
||||||
|
const parameterOverridesStore = useParameterOverridesStore();
|
||||||
|
|
||||||
const canvasEventBus = createEventBus<CanvasEventBusEvents>();
|
const canvasEventBus = createEventBus<CanvasEventBusEvents>();
|
||||||
|
|
||||||
@@ -1161,9 +1165,19 @@ async function onRunWorkflowToNode(id: string) {
|
|||||||
const node = workflowsStore.getNodeById(id);
|
const node = workflowsStore.getNodeById(id);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
trackRunWorkflowToNode(node);
|
if (hasFromAiExpressions(node) && nodeTypesStore.isNodesAsToolNode(node.type)) {
|
||||||
|
uiStore.openModalWithData({
|
||||||
|
name: FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
|
data: {
|
||||||
|
nodeName: node.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
trackRunWorkflowToNode(node);
|
||||||
|
parameterOverridesStore.clearParameterOverrides(workflowsStore.workflowId, node.id);
|
||||||
|
|
||||||
void runWorkflow({ destinationNode: node.name, source: 'Node.executeNode' });
|
void runWorkflow({ destinationNode: node.name, source: 'Node.executeNode' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackRunWorkflowToNode(node: INodeUi) {
|
function trackRunWorkflowToNode(node: INodeUi) {
|
||||||
|
|||||||
@@ -315,3 +315,22 @@ export function traverseNodeParameters(payload: unknown, collectedArgs: FromAIAr
|
|||||||
Object.values(payload).forEach((value) => traverseNodeParameters(value, collectedArgs));
|
Object.values(payload).forEach((value) => traverseNodeParameters(value, collectedArgs));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function traverseNodeParametersWithParamNames(
|
||||||
|
payload: unknown,
|
||||||
|
collectedArgs: Map<string, FromAIArgument>,
|
||||||
|
name?: string,
|
||||||
|
) {
|
||||||
|
if (typeof payload === 'string') {
|
||||||
|
const fromAICalls = extractFromAICalls(payload);
|
||||||
|
fromAICalls.forEach((call) => collectedArgs.set(name as string, call));
|
||||||
|
} else if (Array.isArray(payload)) {
|
||||||
|
payload.forEach((item: unknown, index: number) =>
|
||||||
|
traverseNodeParametersWithParamNames(item, collectedArgs, name + `[${index}]`),
|
||||||
|
);
|
||||||
|
} else if (typeof payload === 'object' && payload !== null) {
|
||||||
|
for (const [key, value] of Object.entries(payload)) {
|
||||||
|
traverseNodeParametersWithParamNames(value, collectedArgs, name ? name + '.' + key : key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1556,5 +1556,9 @@ export function isTriggerNode(nodeTypeData: INodeTypeDescription) {
|
|||||||
export function isExecutable(workflow: Workflow, node: INode, nodeTypeData: INodeTypeDescription) {
|
export function isExecutable(workflow: Workflow, node: INode, nodeTypeData: INodeTypeDescription) {
|
||||||
const outputs = getNodeOutputs(workflow, node, nodeTypeData);
|
const outputs = getNodeOutputs(workflow, node, nodeTypeData);
|
||||||
const outputNames = getConnectionTypes(outputs);
|
const outputNames = getConnectionTypes(outputs);
|
||||||
return outputNames.includes(NodeConnectionTypes.Main) || isTriggerNode(nodeTypeData);
|
return (
|
||||||
|
outputNames.includes(NodeConnectionTypes.Main) ||
|
||||||
|
isTriggerNode(nodeTypeData) ||
|
||||||
|
nodeTypeData.usableAsTool === true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user