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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import type {
NodeParameterValue,
NodeParameterValueType,
} 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 type { INodeUi, IUpdateInformation } from '@/Interface';
@@ -537,7 +537,7 @@ function getDependentParametersValues(parameter: INodeProperties): string | null
function getParameterValue<T extends NodeParameterValueType = NodeParameterValueType>(
name: string,
): 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 {

View File

@@ -46,7 +46,6 @@ import { isObject } from '@/utils/objectUtils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import get from 'lodash/get';
import { useI18n } from '@n8n/i18n';
import { EnableNodeToggleCommand } from '@/models/history';
import { useTelemetry } from './useTelemetry';
@@ -163,10 +162,6 @@ export function useNodeHelpers() {
.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
function displayParameter(
nodeValues: INodeParameters,
@@ -1078,7 +1073,6 @@ export function useNodeHelpers() {
isCustomApiCallSelected,
isNodeExecutable,
getForeignCredentialsIfSharingEnabled,
getParameterValue,
displayParameter,
getNodeIssues,
updateNodesInputIssues,

View File

@@ -1,22 +1,45 @@
import { STORES } from '@n8n/stores';
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 = {
nodeName: string;
nodeId: string;
parameter: INodeProperties;
parameterPath: string;
value: string;
};
export type RichFocusedNodeParameter = FocusedNodeParameter & {
node: INode;
value: NodeParameterValueType;
};
export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
const workflowsStore = useWorkflowsStore();
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) => {
focusedNodeParameters.value = [
_focusedNodeParameters.value = [
nodeParameter,
// Uncomment when tabs are implemented
// ...focusedNodeParameters.value.filter((p) => p.parameterPath !== nodeParameter.parameterPath),
@@ -31,10 +54,17 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
focusPanelActive.value = !focusPanelActive.value;
};
function isRichParameter(
p: RichFocusedNodeParameter | FocusedNodeParameter,
): p is RichFocusedNodeParameter {
return 'value' in p && 'node' in p;
}
return {
focusPanelActive,
focusedNodeParameters,
setFocusedNodeParameter,
isRichParameter,
closeFocusPanel,
toggleFocusPanel,
};

View File

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