mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
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:
@@ -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.",
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') : ''
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2132,7 +2132,7 @@ onBeforeUnmount(() => {
|
|||||||
-->
|
-->
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</WorkflowCanvas>
|
</WorkflowCanvas>
|
||||||
<FocusPanel v-if="isFocusPanelFeatureEnabled" />
|
<FocusPanel v-if="isFocusPanelFeatureEnabled" :executable="!isCanvasReadOnly" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user