diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index 44d205b4ad..6d9e196a8a 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -86,6 +86,7 @@ import IconLucideEarth from '~icons/lucide/earth'; import IconLucideEllipsis from '~icons/lucide/ellipsis'; import IconLucideEllipsisVertical from '~icons/lucide/ellipsis-vertical'; import IconLucideEqual from '~icons/lucide/equal'; +import IconLucideExpand from '~icons/lucide/expand'; import IconLucideExternalLink from '~icons/lucide/external-link'; import IconLucideEye from '~icons/lucide/eye'; import IconLucideEyeOff from '~icons/lucide/eye-off'; @@ -501,6 +502,7 @@ export const updatedIconSet = { ellipsis: IconLucideEllipsis, 'ellipsis-vertical': IconLucideEllipsisVertical, equal: IconLucideEqual, + expand: IconLucideExpand, 'external-link': IconLucideExternalLink, eye: IconLucideEye, 'eye-off': IconLucideEyeOff, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 6fd4c74f65..d310fb3afe 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1533,6 +1533,8 @@ "nodeView.focusPanel.noExecutionData": "Execute previous node for autocomplete", "nodeView.focusPanel.noParameters.title": "Show a node parameter here, to iterate easily", "nodeView.focusPanel.noParameters.subtitle": "For example, keep your prompt always visible so you can run the workflow while tweaking it", + "nodeView.focusPanel.v2.noParameters.title": "Select a node to edit it here", + "nodeView.focusPanel.v2.noParameters.subtitle": "Or show single node parameter you’d like to iterate on by clicking this button next to it:", "nodeView.focusPanel.missingParameter": "This parameter is no longer visible on the node. A related parameter was likely changed, removing this one.", "nodeView.itLooksLikeYouHaveBeenEditingSomething": "It looks like you made some edits. If you leave before saving, your changes will be lost.", "nodeView.loadingTemplate": "Loading template", diff --git a/packages/frontend/editor-ui/src/__tests__/mocks.ts b/packages/frontend/editor-ui/src/__tests__/mocks.ts index 217cc7a938..08f8e5f308 100644 --- a/packages/frontend/editor-ui/src/__tests__/mocks.ts +++ b/packages/frontend/editor-ui/src/__tests__/mocks.ts @@ -30,6 +30,7 @@ import { import type { IExecutionResponse, INodeUi, IWorkflowDb } from '@/Interface'; import { CanvasNodeRenderType } from '@/types'; import type { FrontendSettings } from '@n8n/api-types'; +import type { ExpressionLocalResolveContext } from '@/types/expressions'; export const mockNode = ({ id = uuid(), @@ -69,22 +70,7 @@ export const mockNodeTypeDescription = ({ description, webhooks, eventTriggerDescription, -}: { - name?: INodeTypeDescription['name']; - displayName?: INodeTypeDescription['displayName']; - icon?: INodeTypeDescription['icon']; - version?: INodeTypeDescription['version']; - credentials?: INodeTypeDescription['credentials']; - inputs?: INodeTypeDescription['inputs']; - outputs?: INodeTypeDescription['outputs']; - codex?: INodeTypeDescription['codex']; - properties?: INodeTypeDescription['properties']; - group?: INodeTypeDescription['group']; - hidden?: INodeTypeDescription['hidden']; - description?: INodeTypeDescription['description']; - webhooks?: INodeTypeDescription['webhooks']; - eventTriggerDescription?: INodeTypeDescription['eventTriggerDescription']; -} = {}) => +}: Partial = {}) => mock({ name, icon, @@ -294,3 +280,21 @@ export function createTestWorkflowExecutionResponse( ...data, }; } + +export function createTestExpressionLocalResolveContext( + data: Partial = {}, +): ExpressionLocalResolveContext { + const workflow = data.workflow ?? createTestWorkflowObject(); + + return { + localResolve: true, + workflow, + nodeName: 'n0', + inputNode: { name: 'n1', runIndex: 0, branchIndex: 0 }, + envVars: {}, + additionalKeys: {}, + connections: workflow.connectionsBySourceNode, + execution: null, + ...data, + }; +} diff --git a/packages/frontend/editor-ui/src/components/FocusPanel.test.ts b/packages/frontend/editor-ui/src/components/FocusPanel.test.ts new file mode 100644 index 0000000000..679288d2f9 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/FocusPanel.test.ts @@ -0,0 +1,134 @@ +import { createCanvasGraphNode } from '@/__tests__/data'; +import { createTestNode, createTestWorkflow, mockNodeTypeDescription } from '@/__tests__/mocks'; +import { createComponentRenderer } from '@/__tests__/render'; +import { mockedStore } from '@/__tests__/utils'; +import { SET_NODE_TYPE } from '@/constants'; +import { useFocusPanelStore } from '@/stores/focusPanel.store'; +import { useNodeTypesStore } from '@/stores/nodeTypes.store'; +import { useWorkflowsStore } from '@/stores/workflows.store'; +import { createTestingPinia } from '@pinia/testing'; +import { useVueFlow } from '@vue-flow/core'; +import type { INodeProperties } from 'n8n-workflow'; +import { setActivePinia } from 'pinia'; +import { reactive } from 'vue'; +import { useExperimentalNdvStore } from './canvas/experimental/experimentalNdv.store'; +import FocusPanel from './FocusPanel.vue'; + +vi.mock('vue-router', () => ({ + useRouter: () => ({}), + useRoute: () => reactive({}), + RouterLink: vi.fn(), +})); + +describe('FocusPanel', () => { + const renderComponent = createComponentRenderer(FocusPanel, { + props: { + isCanvasReadOnly: false, + }, + }); + + const parameter0: INodeProperties = { + displayName: 'P0', + name: 'p0', + type: 'string', + default: '', + description: '', + validateType: 'string', + }; + const parameter1: INodeProperties = { + displayName: 'P1', + name: 'p1', + type: 'string', + default: '', + description: '', + validateType: 'string', + }; + + let experimentalNdvStore: ReturnType>; + let focusPanelStore: ReturnType; + let nodeTypesStore: ReturnType; + let workflowsStore: ReturnType; + + beforeEach(() => { + const pinia = setActivePinia(createTestingPinia({ stubActions: false })); + + localStorage.clear(); + + nodeTypesStore = useNodeTypesStore(pinia); + nodeTypesStore.setNodeTypes([ + mockNodeTypeDescription({ + name: SET_NODE_TYPE, + properties: [parameter0, parameter1], + }), + ]); + workflowsStore = useWorkflowsStore(pinia); + workflowsStore.setWorkflow(createTestWorkflow({ id: 'w0' })); + workflowsStore.setNodes([ + createTestNode({ id: 'n0', name: 'N0', parameters: { p0: 'v0' }, type: SET_NODE_TYPE }), + ]); + focusPanelStore = useFocusPanelStore(pinia); + focusPanelStore.toggleFocusPanel(); + }); + + describe('when experimental NDV is enabled', () => { + beforeEach(() => { + experimentalNdvStore = mockedStore(useExperimentalNdvStore); + experimentalNdvStore.isNdvInFocusPanelEnabled = true; + }); + + it('should render empty state when neither a node nor a parameter is selected', async () => { + const rendered = renderComponent({}); + + expect(await rendered.findByText('Select a node to edit it here')); + }); + + it('should render the parameter focus input when a parameter is selected', async () => { + const rendered = renderComponent({}); + + focusPanelStore.openWithFocusedNodeParameter({ + nodeId: 'n0', + parameter: parameter0, + parameterPath: 'parameters.p0', + }); + + expect(await rendered.findByTestId('focus-parameter')).toBeInTheDocument(); + expect(rendered.getByText('N0')).toBeInTheDocument(); // title in header + expect(rendered.getByText('P0')).toBeInTheDocument(); // title in header + expect(rendered.getByDisplayValue('v0')).toBeInTheDocument(); // current value of the parameter + }); + + it('should render node parameters when a node is selected on canvas', async () => { + const graphNode = createCanvasGraphNode({ id: 'n0' }); + const vueFlow = useVueFlow('w0'); + const rendered = renderComponent({}); + + vueFlow.addNodes([graphNode]); + vueFlow.addSelectedNodes([graphNode]); + + expect(await rendered.findByTestId('node-parameters')).toBeInTheDocument(); + expect(rendered.getByText('N0')).toBeInTheDocument(); // title in header + expect(rendered.getByText('P0')).toBeInTheDocument(); // parameter 0 + expect(rendered.getByText('P1')).toBeInTheDocument(); // parameter 1 + }); + + it('should render the parameters when a node is selected on canvas and a parameter is selected', async () => { + const graphNode = createCanvasGraphNode({ id: 'n0' }); + const vueFlow = useVueFlow('w0'); + const rendered = renderComponent({}); + + vueFlow.addNodes([graphNode]); + vueFlow.addSelectedNodes([graphNode]); + + focusPanelStore.openWithFocusedNodeParameter({ + nodeId: 'n0', + parameter: parameter0, + parameterPath: 'parameters.p0', + }); + + expect(await rendered.findByTestId('focus-parameter')).toBeInTheDocument(); + expect(rendered.getByText('N0')).toBeInTheDocument(); // title in header + expect(rendered.getByText('P0')).toBeInTheDocument(); // title in header + expect(rendered.getByDisplayValue('v0')).toBeInTheDocument(); // current value of the parameter + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/FocusPanel.vue b/packages/frontend/editor-ui/src/components/FocusPanel.vue index 2148ca2f5c..3601d72014 100644 --- a/packages/frontend/editor-ui/src/components/FocusPanel.vue +++ b/packages/frontend/editor-ui/src/components/FocusPanel.vue @@ -38,6 +38,7 @@ import { useVueFlow } from '@vue-flow/core'; import ExperimentalFocusPanelHeader from '@/components/canvas/experimental/components/ExperimentalFocusPanelHeader.vue'; import { useTelemetryContext } from '@/composables/useTelemetryContext'; import { type ContextMenuAction } from '@/composables/useContextMenuItems'; +import { type CanvasNode, CanvasNodeRenderType } from '@/types'; defineOptions({ name: 'FocusPanel' }); @@ -112,9 +113,11 @@ const node = computed(() => { return resolvedParameter.value?.node; } - const selected = vueFlow.getSelectedNodes.value[0]?.id; + const selected: CanvasNode | undefined = vueFlow.getSelectedNodes.value[0]; - return selected ? workflowsStore.allNodes.find((n) => n.id === selected) : undefined; + return selected?.data?.render.type === CanvasNodeRenderType.Default + ? workflowsStore.allNodes.find((n) => n.id === selected.id) + : undefined; }); const multipleNodesSelected = computed(() => vueFlow.getSelectedNodes.value.length > 1); @@ -209,6 +212,18 @@ const isNodeExecuting = computed(() => workflowsStore.isNodeExecuting(node.value const selectedNodeIds = computed(() => vueFlow.getSelectedNodes.value.map((n) => n.id)); +const emptyTitle = computed(() => + experimentalNdvStore.isNdvInFocusPanelEnabled + ? locale.baseText('nodeView.focusPanel.v2.noParameters.title') + : locale.baseText('nodeView.focusPanel.noParameters.title'), +); + +const emptySubtitle = computed(() => + experimentalNdvStore.isNdvInFocusPanelEnabled + ? locale.baseText('nodeView.focusPanel.v2.noParameters.subtitle') + : locale.baseText('nodeView.focusPanel.noParameters.subtitle'), +); + const { resolvedExpression } = useResolvedExpression({ expression, additionalData: resolvedAdditionalExpressionData, @@ -407,7 +422,16 @@ function onOpenNdv() {
diff --git a/packages/frontend/editor-ui/src/components/canvas/experimental/components/ExperimentalEmbeddedNdvActions.vue b/packages/frontend/editor-ui/src/components/canvas/experimental/components/ExperimentalEmbeddedNdvActions.vue index ea4aa90627..b4b88d8c64 100644 --- a/packages/frontend/editor-ui/src/components/canvas/experimental/components/ExperimentalEmbeddedNdvActions.vue +++ b/packages/frontend/editor-ui/src/components/canvas/experimental/components/ExperimentalEmbeddedNdvActions.vue @@ -9,7 +9,7 @@ const emit = defineEmits<{ openNdv: []; toggleExpand: [] }>();