diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index c88426bbc2..7a5ff71446 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/@n8n/stores/src/constants.ts b/packages/frontend/@n8n/stores/src/constants.ts index 46b628956b..15fc0862ed 100644 --- a/packages/frontend/@n8n/stores/src/constants.ts +++ b/packages/frontend/@n8n/stores/src/constants.ts @@ -30,4 +30,5 @@ export const STORES = { EVALUATION: 'evaluation', FOLDERS: 'folders', MODULES: 'modules', + FOCUS_PANEL: 'focusPanel', } as const; diff --git a/packages/frontend/editor-ui/src/components/FocusPanel.vue b/packages/frontend/editor-ui/src/components/FocusPanel.vue new file mode 100644 index 0000000000..6f54534325 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/FocusPanel.vue @@ -0,0 +1,195 @@ + + + + + + + {{ locale.baseText('nodeView.focusPanel.title') }} + + + + + + + + + {{ + focusedNodeParameter.parameter.displayName + }} + {{ focusedNodeParameter.nodeName }} + + + + + + {{ locale.baseText('nodeView.focusPanel.executeButtonTooltip') }} + + + + + + + + + + + + + + + + + + + {{ locale.baseText('nodeView.focusPanel.noParameters') }} + + + + + + + diff --git a/packages/frontend/editor-ui/src/components/ParameterInput.vue b/packages/frontend/editor-ui/src/components/ParameterInput.vue index 778c369a3d..b92e9313ee 100644 --- a/packages/frontend/editor-ui/src/components/ParameterInput.vue +++ b/packages/frontend/editor-ui/src/components/ParameterInput.vue @@ -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(() => { diff --git a/packages/frontend/editor-ui/src/components/ParameterOptions.vue b/packages/frontend/editor-ui/src/components/ParameterOptions.vue index 57f02b2eff..416fb9e40e 100644 --- a/packages/frontend/editor-ui/src/components/ParameterOptions.vue +++ b/packages/frontend/editor-ui/src/components/ParameterOptions.vue @@ -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 [ - { - label: i18n.baseText('parameterInput.formatHtml'), - value: 'formatHtml', - }, - ]; + const formatHtmlAction = { + label: i18n.baseText('parameterInput.formatHtml'), + value: 'formatHtml', + }; + + return hasFocusAction.value ? [formatHtmlAction, focusAction] : [formatHtmlAction]; } - const parameterActions = [ - { - label: resetValueLabel.value, - value: 'resetValue', - disabled: isDefault.value, - }, - ]; + const resetAction = { + label: resetValueLabel.value, + value: 'resetValue', + disabled: isDefault.value, + }; + + const parameterActions = hasFocusAction.value ? [resetAction, focusAction] : [resetAction]; if ( hasRemoteMethod.value || diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index 4b0dbd2917..c7005268ea 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/stores/focusPanel.store.ts b/packages/frontend/editor-ui/src/stores/focusPanel.store.ts new file mode 100644 index 0000000000..a1a352f4c3 --- /dev/null +++ b/packages/frontend/editor-ui/src/stores/focusPanel.store.ts @@ -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([]); + + 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, + }; +}); diff --git a/packages/frontend/editor-ui/src/views/NodeView.vue b/packages/frontend/editor-ui/src/views/NodeView.vue index 5b629af378..468e3b4e15 100644 --- a/packages/frontend/editor-ui/src/views/NodeView.vue +++ b/packages/frontend/editor-ui/src/views/NodeView.vue @@ -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); @@ -1981,146 +1988,154 @@ onBeforeUnmount(() => { - - - - - - - - - - - - - - - - - + - {{ i18n.baseText('readOnlyEnv.cantEditOrRun') }} - + + + + + + + + + + + + + + - - - - - - - - + + + +