feat(editor): Add focus panel component (no-changelog) (#16620)

This commit is contained in:
Daria
2025-06-24 18:13:09 +03:00
committed by GitHub
parent 92afe036dd
commit 404f8451d5
8 changed files with 448 additions and 148 deletions

View File

@@ -1473,6 +1473,9 @@
"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.",
"nodeView.loadingTemplate": "Loading template",
"nodeView.moreInfo": "More info",
@@ -1610,6 +1613,7 @@
"parameterInput.parameterHasIssuesAndExpression": "Parameter: \"{shortPath}\" has issues and an expression",
"parameterInput.refreshList": "Refresh List",
"parameterInput.clearContents": "Clear Contents",
"parameterInput.focusParameter": "Focus parameter",
"parameterInput.resetValue": "Reset Value",
"parameterInput.select": "Select",
"parameterInput.selectDateAndTime": "Select date and time",

View File

@@ -30,4 +30,5 @@ export const STORES = {
EVALUATION: 'evaluation',
FOLDERS: 'folders',
MODULES: 'modules',
FOCUS_PANEL: 'focusPanel',
} as const;

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
import { useFocusPanelStore } from '@/stores/focusPanel.store';
import { N8nText, N8nInput } from '@n8n/design-system';
import { computed } from 'vue';
import { useI18n } from '@n8n/i18n';
import { isValueExpression } from '@/utils/nodeTypesUtils';
defineOptions({ name: 'FocusPanel' });
const locale = useI18n();
const focusPanelStore = useFocusPanelStore();
const focusedNodeParameter = computed(() => focusPanelStore.focusedNodeParameters[0]);
const focusPanelActive = computed(() => focusPanelStore.focusPanelActive);
const expressionModeEnabled = computed(
() =>
focusedNodeParameter.value &&
isValueExpression(focusedNodeParameter.value.parameter, focusedNodeParameter.value.value),
);
function optionSelected() {
// TODO: Handle the option selected (command: string) from the dropdown
}
function valueChanged() {
// TODO: Update parameter value
}
function executeFocusedNode() {
// TODO: Implement execution of the focused node
}
</script>
<template>
<div v-if="focusPanelActive" :class="$style.container">
<div :class="$style.header">
<N8nText size="small" :bold="true">
{{ locale.baseText('nodeView.focusPanel.title') }}
</N8nText>
<div :class="$style.closeButton" @click="focusPanelStore.closeFocusPanel">
<n8n-icon icon="arrow-right" color="text-base" />
</div>
</div>
<div v-if="focusedNodeParameter" :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>
</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>
</div>
<div :class="$style.parameterDetailsWrapper">
<div :class="$style.parameterOptionsWrapper">
<div></div>
<ParameterOptions
:parameter="focusedNodeParameter.parameter"
:value="focusedNodeParameter.value"
:is-read-only="false"
@update:model-value="optionSelected"
/>
</div>
<div :class="$style.editorContainer">
<ExpressionEditorModalInput
v-if="expressionModeEnabled"
:model-value="focusedNodeParameter.value"
:class="$style.editor"
:is-read-only="false"
:path="focusedNodeParameter.parameterPath"
data-test-id="expression-modal-input"
:target-node-parameter-context="{
nodeName: focusedNodeParameter.nodeName,
parameterPath: focusedNodeParameter.parameterPath,
}"
@change="valueChanged"
/>
<N8nInput
v-else
v-model="focusedNodeParameter.value"
:class="$style.editor"
type="textarea"
resize="none"
></N8nInput>
</div>
</div>
</div>
<div v-else :class="[$style.content, $style.emptyContent]">
<div :class="$style.emptyText">
<N8nText color="text-base">
{{ locale.baseText('nodeView.focusPanel.noParameters') }}
</N8nText>
</div>
</div>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
width: 528px;
border-left: 1px solid var(--color-foreground-base);
background: var(--color-background-base);
}
.closeButton:hover {
cursor: pointer;
}
.header {
display: flex;
padding: var(--spacing-2xs);
justify-content: space-between;
border-bottom: 1px solid var(--color-foreground-base);
background: var(--color-background-xlight);
}
.content {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
&.emptyContent {
text-align: center;
justify-content: center;
align-items: center;
.emptyText {
max-width: 300px;
}
}
.tabHeader {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--color-foreground-base);
padding: var(--spacing-2xs);
.tabHeaderText {
display: flex;
gap: var(--spacing-4xs);
align-items: center;
}
.buttonWrapper {
display: flex;
padding: 6px 8px 6px 34px;
justify-content: flex-end;
}
}
.parameterDetailsWrapper {
display: flex;
height: 100%;
flex-direction: column;
gap: var(--spacing-2xs);
padding: var(--spacing-2xs);
.parameterOptionsWrapper {
display: flex;
justify-content: space-between;
}
.editorContainer {
display: flex;
height: 100%;
.editor {
display: flex;
height: 100%;
width: 100%;
font-size: var(--font-size-xs);
}
}
}
}
</style>

View File

@@ -70,6 +70,7 @@ import { completeExpressionSyntax, shouldConvertToExpression } from '@/utils/exp
import { isPresent } from '@/utils/typesUtils';
import CssEditor from './CssEditor/CssEditor.vue';
import { useUIStore } from '@/stores/ui.store';
import { useFocusPanelStore } from '@/stores/focusPanel.store';
type Picker = { $emit: (arg0: string, arg1: Date) => void };
@@ -132,6 +133,7 @@ const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const focusPanelStore = useFocusPanelStore();
// ESLint: false positive
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents, @typescript-eslint/no-duplicate-type-constituents
@@ -1043,6 +1045,23 @@ async function optionSelected(command: string) {
telemetry.track('User switched parameter mode', telemetryPayload);
void externalHooks.run('parameterInput.modeSwitch', telemetryPayload);
}
if (node.value && command === 'focus') {
focusPanelStore.setFocusedNodeParameter({
nodeName: node.value.name,
parameterPath: props.path,
parameter: props.parameter,
value: modelValueString.value,
});
if (ndvStore.activeNode) {
ndvStore.activeNodeName = null;
// TODO: check what this does - close method on NodeDetailsView
ndvStore.resetNDVPushRef();
}
focusPanelStore.focusPanelActive = true;
}
}
onMounted(() => {

View File

@@ -8,7 +8,8 @@ import {
import { isValueExpression } from '@/utils/nodeTypesUtils';
import { computed } from 'vue';
import { useNDVStore } from '@/stores/ndv.store';
import { AI_TRANSFORM_NODE_TYPE } from '@/constants';
import { usePostHog } from '@/stores/posthog.store';
import { AI_TRANSFORM_NODE_TYPE, FOCUS_PANEL_EXPERIMENT } from '@/constants';
interface Props {
parameter: INodeProperties;
@@ -37,6 +38,8 @@ const emit = defineEmits<{
}>();
const i18n = useI18n();
const ndvStore = useNDVStore();
const posthogStore = usePostHog();
const isDefault = computed(() => props.parameter.default === props.value);
const isValueAnExpression = computed(() => isValueExpression(props.parameter, props.value));
@@ -53,6 +56,10 @@ const shouldShowOptions = computed(() => {
return false;
}
if (hasFocusAction.value) {
return true;
}
if (['codeNodeEditor', 'sqlEditor'].includes(props.parameter.typeOptions?.editor ?? '')) {
return false;
}
@@ -64,7 +71,7 @@ const shouldShowOptions = computed(() => {
return false;
});
const selectedView = computed(() => (isValueAnExpression.value ? 'expression' : 'fixed'));
const activeNode = computed(() => useNDVStore().activeNode);
const activeNode = computed(() => ndvStore.activeNode);
const hasRemoteMethod = computed(
() =>
!!props.parameter.typeOptions?.loadOptionsMethod || !!props.parameter.typeOptions?.loadOptions,
@@ -77,27 +84,44 @@ const resetValueLabel = computed(() => {
return i18n.baseText('parameterInput.resetValue');
});
const isFocusPanelFeatureEnabled = computed(() => {
return posthogStore.getVariant(FOCUS_PANEL_EXPERIMENT.name) === FOCUS_PANEL_EXPERIMENT.variant;
});
const hasFocusAction = computed(
() =>
isFocusPanelFeatureEnabled.value &&
!props.isReadOnly &&
activeNode.value &&
(props.parameter.type === 'string' || props.parameter.type === 'json'),
);
const actions = computed(() => {
if (Array.isArray(props.customActions) && props.customActions.length > 0) {
return props.customActions;
}
const focusAction = {
label: i18n.baseText('parameterInput.focusParameter'),
value: 'focus',
disabled: false,
};
if (isHtmlEditor.value && !isValueAnExpression.value) {
return [
{
const formatHtmlAction = {
label: i18n.baseText('parameterInput.formatHtml'),
value: 'formatHtml',
},
];
};
return hasFocusAction.value ? [formatHtmlAction, focusAction] : [formatHtmlAction];
}
const parameterActions = [
{
const resetAction = {
label: resetValueLabel.value,
value: 'resetValue',
disabled: isDefault.value,
},
];
};
const parameterActions = hasFocusAction.value ? [resetAction, focusAction] : [resetAction];
if (
hasRemoteMethod.value ||

View File

@@ -745,6 +745,12 @@ export const RAG_STARTER_WORKFLOW_EXPERIMENT = {
variant: 'variant',
};
export const FOCUS_PANEL_EXPERIMENT = {
name: 'focus_panel',
control: 'control',
variant: 'variant',
};
export const EXPERIMENTS_TO_TRACK = [
EASY_AI_WORKFLOW_EXPERIMENT.name,
AI_CREDITS_EXPERIMENT.name,

View File

@@ -0,0 +1,36 @@
import { STORES } from '@n8n/stores';
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { INodeProperties } from 'n8n-workflow';
type FocusedNodeParameter = {
nodeName: string;
parameter: INodeProperties;
parameterPath: string;
value: string;
};
export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
const focusPanelActive = ref(false);
const focusedNodeParameters = ref<FocusedNodeParameter[]>([]);
const setFocusedNodeParameter = (nodeParameter: FocusedNodeParameter) => {
focusedNodeParameters.value = [
nodeParameter,
// Uncomment when tabs are implemented
// ...focusedNodeParameters.value.filter((p) => p.parameterPath !== nodeParameter.parameterPath),
];
};
const closeFocusPanel = () => {
focusPanelActive.value = false;
};
return {
focusPanelActive,
focusedNodeParameters,
setFocusedNodeParameter,
closeFocusPanel,
};
});

View File

@@ -15,6 +15,7 @@ import {
} from 'vue';
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
import FocusPanel from '@/components/FocusPanel.vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useUIStore } from '@/stores/ui.store';
import CanvasRunWorkflowButton from '@/components/canvas/elements/buttons/CanvasRunWorkflowButton.vue';
@@ -54,6 +55,7 @@ import {
CHAT_TRIGGER_NODE_TYPE,
DRAG_EVENT_DATA_KEY,
EnterpriseEditionFeature,
FOCUS_PANEL_EXPERIMENT,
FROM_AI_PARAMETERS_MODAL_KEY,
MAIN_HEADER_TABS,
MANUAL_CHAT_TRIGGER_NODE_TYPE,
@@ -69,6 +71,7 @@ import {
} from '@/constants';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { usePostHog } from '@/stores/posthog.store';
import { useExternalHooks } from '@/composables/useExternalHooks';
import {
NodeConnectionTypes,
@@ -242,6 +245,10 @@ const { extractWorkflow } = useWorkflowExtraction();
const { applyExecutionData } = useExecutionDebugging();
useClipboard({ onPaste: onClipboardPaste });
const isFocusPanelFeatureEnabled = computed(() => {
return usePostHog().getVariant(FOCUS_PANEL_EXPERIMENT.name) === FOCUS_PANEL_EXPERIMENT.variant;
});
const isLoading = ref(true);
const isBlankRedirect = ref(false);
const readOnlyNotification = ref<null | { visible: boolean }>(null);
@@ -1981,6 +1988,7 @@ onBeforeUnmount(() => {
</script>
<template>
<div :class="$style.wrapper">
<WorkflowCanvas
v-if="editableWorkflow && editableWorkflowObject && !isLoading"
:id="editableWorkflow.id"
@@ -2118,9 +2126,16 @@ onBeforeUnmount(() => {
-->
</Suspense>
</WorkflowCanvas>
<FocusPanel v-if="isFocusPanelFeatureEnabled" />
</div>
</template>
<style lang="scss" module>
.wrapper {
display: flex;
width: 100%;
}
.executionButtons {
position: absolute;
display: flex;