feat(editor): Implement execute step mechanism for focused panel (no-changelog) (#16891)

Co-authored-by: Charlie Kolb <charlie@n8n.io>
Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
Daria
2025-07-02 19:23:48 +03:00
committed by GitHub
parent 5c5c06aa58
commit 2d2818cdf8
8 changed files with 106 additions and 51 deletions

View File

@@ -1478,7 +1478,6 @@
"nodeView.couldntImportWorkflow": "Could not import workflow", "nodeView.couldntImportWorkflow": "Could not import workflow",
"nodeView.couldntLoadWorkflow.invalidWorkflowObject": "Invalid workflow object", "nodeView.couldntLoadWorkflow.invalidWorkflowObject": "Invalid workflow object",
"nodeView.deletesTheCurrentExecutionData": "Deletes the current execution data", "nodeView.deletesTheCurrentExecutionData": "Deletes the current execution data",
"nodeView.focusPanel.executeButtonTooltip": "Execute AI Agent",
"nodeView.focusPanel.title": "Focus", "nodeView.focusPanel.title": "Focus",
"nodeView.focusPanel.noParameters": "No parameters focused. Focus a parameter by clicking on the action dropdown in the node detail view.", "nodeView.focusPanel.noParameters": "No parameters focused. Focus a parameter by clicking on the action dropdown in the node detail view.",
"nodeView.itLooksLikeYouHaveBeenEditingSomething": "It looks like you made some edits. If you leave before saving, your changes will be lost.", "nodeView.itLooksLikeYouHaveBeenEditingSomething": "It looks like you made some edits. If you leave before saving, your changes will be lost.",

View File

@@ -4,21 +4,44 @@ import { N8nText, N8nInput } from '@n8n/design-system';
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { isValueExpression } from '@/utils/nodeTypesUtils'; import { isValueExpression } from '@/utils/nodeTypesUtils';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
defineOptions({ name: 'FocusPanel' }); defineOptions({ name: 'FocusPanel' });
const locale = useI18n(); const props = defineProps<{
executable: boolean;
}>();
const locale = useI18n();
const nodeHelpers = useNodeHelpers();
const focusPanelStore = useFocusPanelStore(); const focusPanelStore = useFocusPanelStore();
const focusedNodeParameter = computed(() => focusPanelStore.focusedNodeParameters[0]); const focusedNodeParameter = computed(() => focusPanelStore.focusedNodeParameters[0]);
const resolvedParameter = computed(() =>
focusedNodeParameter.value && focusPanelStore.isRichParameter(focusedNodeParameter.value)
? focusedNodeParameter.value
: undefined,
);
const focusPanelActive = computed(() => focusPanelStore.focusPanelActive); const focusPanelActive = computed(() => focusPanelStore.focusPanelActive);
const isExecutable = computed(() => {
if (!resolvedParameter.value) return false;
const foreignCredentials = nodeHelpers.getForeignCredentialsIfSharingEnabled(
resolvedParameter.value.node.credentials,
);
return nodeHelpers.isNodeExecutable(
resolvedParameter.value.node,
props.executable,
foreignCredentials,
);
});
const expressionModeEnabled = computed( const expressionModeEnabled = computed(
() => () =>
focusedNodeParameter.value && resolvedParameter.value &&
isValueExpression(focusedNodeParameter.value.parameter, focusedNodeParameter.value.value), isValueExpression(resolvedParameter.value.parameter, resolvedParameter.value.value),
); );
function optionSelected() { function optionSelected() {
@@ -28,10 +51,6 @@ function optionSelected() {
function valueChanged() { function valueChanged() {
// TODO: Update parameter value // TODO: Update parameter value
} }
function executeFocusedNode() {
// TODO: Implement execution of the focused node
}
</script> </script>
<template> <template>
@@ -44,55 +63,53 @@ function executeFocusedNode() {
<n8n-icon icon="arrow-right" color="text-base" /> <n8n-icon icon="arrow-right" color="text-base" />
</div> </div>
</div> </div>
<div v-if="focusedNodeParameter" :class="$style.content"> <div v-if="resolvedParameter" :class="$style.content">
<div :class="$style.tabHeader"> <div :class="$style.tabHeader">
<div :class="$style.tabHeaderText"> <div :class="$style.tabHeaderText">
<N8nText color="text-dark" size="small">{{ <N8nText color="text-dark" size="small">
focusedNodeParameter.parameter.displayName {{ resolvedParameter.parameter.displayName }}
}}</N8nText> </N8nText>
<N8nText color="text-base" size="xsmall">{{ focusedNodeParameter.nodeName }}</N8nText> <N8nText color="text-base" size="xsmall">{{ resolvedParameter.node.name }}</N8nText>
</div> </div>
<N8nTooltip> <NodeExecuteButton
<n8n-button data-test-id="node-execute-button"
v-bind="{ icon: 'play', square: true }" :node-name="resolvedParameter.node.name"
size="small" :tooltip="`Execute ${resolvedParameter.node.name}`"
type="primary" :disabled="!isExecutable"
@click="executeFocusedNode" size="small"
/> icon="play"
<template #content> :square="true"
<N8nText size="small"> :hide-label="true"
{{ locale.baseText('nodeView.focusPanel.executeButtonTooltip') }} telemetry-source="focus"
</N8nText> ></NodeExecuteButton>
</template>
</N8nTooltip>
</div> </div>
<div :class="$style.parameterDetailsWrapper"> <div :class="$style.parameterDetailsWrapper">
<div :class="$style.parameterOptionsWrapper"> <div :class="$style.parameterOptionsWrapper">
<div></div> <div></div>
<ParameterOptions <ParameterOptions
:parameter="focusedNodeParameter.parameter" :parameter="resolvedParameter.parameter"
:value="focusedNodeParameter.value" :value="resolvedParameter.value"
:is-read-only="false" :is-read-only="false"
@update:model-value="optionSelected" @update:model-value="optionSelected"
/> />
</div> </div>
<div :class="$style.editorContainer"> <div v-if="typeof resolvedParameter.value === 'string'" :class="$style.editorContainer">
<ExpressionEditorModalInput <ExpressionEditorModalInput
v-if="expressionModeEnabled" v-if="expressionModeEnabled"
:model-value="focusedNodeParameter.value" :model-value="resolvedParameter.value"
:class="$style.editor" :class="$style.editor"
:is-read-only="false" :is-read-only="false"
:path="focusedNodeParameter.parameterPath" :path="resolvedParameter.parameterPath"
data-test-id="expression-modal-input" data-test-id="expression-modal-input"
:target-node-parameter-context="{ :target-node-parameter-context="{
nodeName: focusedNodeParameter.nodeName, nodeName: resolvedParameter.node.name,
parameterPath: focusedNodeParameter.parameterPath, parameterPath: resolvedParameter.parameterPath,
}" }"
@change="valueChanged" @change="valueChanged"
/> />
<N8nInput <N8nInput
v-else v-else
v-model="focusedNodeParameter.value" v-model="resolvedParameter.value"
:class="$style.editor" :class="$style.editor"
type="textarea" type="textarea"
resize="none" resize="none"
@@ -117,6 +134,7 @@ function executeFocusedNode() {
width: 528px; width: 528px;
border-left: 1px solid var(--color-foreground-base); border-left: 1px solid var(--color-foreground-base);
background: var(--color-background-base); background: var(--color-background-base);
overflow-y: hidden;
} }
.closeButton:hover { .closeButton:hover {
@@ -182,12 +200,17 @@ function executeFocusedNode() {
.editorContainer { .editorContainer {
display: flex; display: flex;
height: 100%; height: 100%;
overflow-y: auto;
.editor { .editor {
display: flex; display: flex;
height: 100%; height: 100%;
width: 100%; width: 100%;
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
:global(.cm-editor) {
width: 100%;
}
} }
} }
} }

View File

@@ -45,13 +45,17 @@ const props = withDefaults(
label?: string; label?: string;
type?: ButtonType; type?: ButtonType;
size?: ButtonSize; size?: ButtonSize;
icon?: IconName;
square?: boolean;
transparent?: boolean; transparent?: boolean;
hideIcon?: boolean; hideIcon?: boolean;
hideLabel?: boolean;
tooltip?: string; tooltip?: string;
}>(), }>(),
{ {
disabled: false, disabled: false,
transparent: false, transparent: false,
square: false,
}, },
); );
@@ -189,6 +193,10 @@ const tooltipText = computed(() => {
}); });
const buttonLabel = computed(() => { const buttonLabel = computed(() => {
if (props.hideLabel) {
return '';
}
if (isListeningForEvents.value || isListeningForWorkflowEvents.value) { if (isListeningForEvents.value || isListeningForWorkflowEvents.value) {
return i18n.baseText('ndv.execute.stopListening'); return i18n.baseText('ndv.execute.stopListening');
} }
@@ -223,6 +231,7 @@ const isLoading = computed(
); );
const buttonIcon = computed((): IconName | undefined => { const buttonIcon = computed((): IconName | undefined => {
if (props.icon) return props.icon;
if (shouldGenerateCode.value) return 'terminal'; if (shouldGenerateCode.value) return 'terminal';
if (!isListeningForEvents.value && !props.hideIcon) return 'flask-conical'; if (!isListeningForEvents.value && !props.hideIcon) return 'flask-conical';
return undefined; return undefined;
@@ -387,6 +396,7 @@ async function onClick() {
:type="type" :type="type"
:size="size" :size="size"
:icon="buttonIcon" :icon="buttonIcon"
:square="square"
:transparent-background="transparent" :transparent-background="transparent"
:title=" :title="
!isTriggerNode && !tooltipText ? i18n.baseText('ndv.execute.testNode.description') : '' !isTriggerNode && !tooltipText ? i18n.baseText('ndv.execute.testNode.description') : ''

View File

@@ -1048,10 +1048,9 @@ async function optionSelected(command: string) {
if (node.value && command === 'focus') { if (node.value && command === 'focus') {
focusPanelStore.setFocusedNodeParameter({ focusPanelStore.setFocusedNodeParameter({
nodeName: node.value.name, nodeId: node.value.id,
parameterPath: props.path, parameterPath: props.path,
parameter: props.parameter, parameter: props.parameter,
value: modelValueString.value,
}); });
if (ndvStore.activeNode) { if (ndvStore.activeNode) {

View File

@@ -6,7 +6,7 @@ import type {
NodeParameterValue, NodeParameterValue,
NodeParameterValueType, NodeParameterValueType,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ADD_FORM_NOTICE, deepCopy, NodeHelpers } from 'n8n-workflow'; import { ADD_FORM_NOTICE, deepCopy, getParameterValueByPath, NodeHelpers } from 'n8n-workflow';
import { computed, defineAsyncComponent, onErrorCaptured, ref, watch, type WatchSource } from 'vue'; import { computed, defineAsyncComponent, onErrorCaptured, ref, watch, type WatchSource } from 'vue';
import type { INodeUi, IUpdateInformation } from '@/Interface'; import type { INodeUi, IUpdateInformation } from '@/Interface';
@@ -537,7 +537,7 @@ function getDependentParametersValues(parameter: INodeProperties): string | null
function getParameterValue<T extends NodeParameterValueType = NodeParameterValueType>( function getParameterValue<T extends NodeParameterValueType = NodeParameterValueType>(
name: string, name: string,
): T { ): T {
return nodeHelpers.getParameterValue(props.nodeValues, name, props.path) as T; return getParameterValueByPath(props.nodeValues, name, props.path) as T;
} }
function isRagStarterCallout(parameter: INodeProperties): boolean { function isRagStarterCallout(parameter: INodeProperties): boolean {

View File

@@ -46,7 +46,6 @@ import { isObject } from '@/utils/objectUtils';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
import get from 'lodash/get';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { EnableNodeToggleCommand } from '@/models/history'; import { EnableNodeToggleCommand } from '@/models/history';
import { useTelemetry } from './useTelemetry'; import { useTelemetry } from './useTelemetry';
@@ -163,10 +162,6 @@ export function useNodeHelpers() {
.filter((id) => id in usedCredentials && !usedCredentials[id]?.currentUserHasAccess); .filter((id) => id in usedCredentials && !usedCredentials[id]?.currentUserHasAccess);
} }
function getParameterValue(nodeValues: INodeParameters, parameterName: string, path: string) {
return get(nodeValues, path ? path + '.' + parameterName : parameterName);
}
// Returns if the given parameter should be displayed or not // Returns if the given parameter should be displayed or not
function displayParameter( function displayParameter(
nodeValues: INodeParameters, nodeValues: INodeParameters,
@@ -1078,7 +1073,6 @@ export function useNodeHelpers() {
isCustomApiCallSelected, isCustomApiCallSelected,
isNodeExecutable, isNodeExecutable,
getForeignCredentialsIfSharingEnabled, getForeignCredentialsIfSharingEnabled,
getParameterValue,
displayParameter, displayParameter,
getNodeIssues, getNodeIssues,
updateNodesInputIssues, updateNodesInputIssues,

View File

@@ -1,22 +1,45 @@
import { STORES } from '@n8n/stores'; import { STORES } from '@n8n/stores';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref } from 'vue'; import { computed, ref } from 'vue';
import get from 'lodash/get';
import type { INodeProperties } from 'n8n-workflow'; import { type NodeParameterValueType, type INode, type INodeProperties } from 'n8n-workflow';
import { useWorkflowsStore } from './workflows.store';
type FocusedNodeParameter = { type FocusedNodeParameter = {
nodeName: string; nodeId: string;
parameter: INodeProperties; parameter: INodeProperties;
parameterPath: string; parameterPath: string;
value: string; };
export type RichFocusedNodeParameter = FocusedNodeParameter & {
node: INode;
value: NodeParameterValueType;
}; };
export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => { export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
const workflowsStore = useWorkflowsStore();
const focusPanelActive = ref(false); const focusPanelActive = ref(false);
const focusedNodeParameters = ref<FocusedNodeParameter[]>([]); const _focusedNodeParameters = ref<FocusedNodeParameter[]>([]);
// An unenriched parameter indicates a missing nodeId
const focusedNodeParameters = computed<Array<RichFocusedNodeParameter | FocusedNodeParameter>>(
() =>
_focusedNodeParameters.value.map((x) => {
const node = workflowsStore.getNodeById(x.nodeId);
if (!node) return x;
return {
...x,
node,
value: get(node?.parameters ?? {}, x.parameterPath.replace(/parameters\./, '')),
} satisfies RichFocusedNodeParameter;
}),
);
const setFocusedNodeParameter = (nodeParameter: FocusedNodeParameter) => { const setFocusedNodeParameter = (nodeParameter: FocusedNodeParameter) => {
focusedNodeParameters.value = [ _focusedNodeParameters.value = [
nodeParameter, nodeParameter,
// Uncomment when tabs are implemented // Uncomment when tabs are implemented
// ...focusedNodeParameters.value.filter((p) => p.parameterPath !== nodeParameter.parameterPath), // ...focusedNodeParameters.value.filter((p) => p.parameterPath !== nodeParameter.parameterPath),
@@ -31,10 +54,17 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
focusPanelActive.value = !focusPanelActive.value; focusPanelActive.value = !focusPanelActive.value;
}; };
function isRichParameter(
p: RichFocusedNodeParameter | FocusedNodeParameter,
): p is RichFocusedNodeParameter {
return 'value' in p && 'node' in p;
}
return { return {
focusPanelActive, focusPanelActive,
focusedNodeParameters, focusedNodeParameters,
setFocusedNodeParameter, setFocusedNodeParameter,
isRichParameter,
closeFocusPanel, closeFocusPanel,
toggleFocusPanel, toggleFocusPanel,
}; };

View File

@@ -2132,7 +2132,7 @@ onBeforeUnmount(() => {
--> -->
</Suspense> </Suspense>
</WorkflowCanvas> </WorkflowCanvas>
<FocusPanel v-if="isFocusPanelFeatureEnabled" /> <FocusPanel v-if="isFocusPanelFeatureEnabled" :executable="!isCanvasReadOnly" />
</div> </div>
</template> </template>