diff --git a/packages/editor-ui/src/__tests__/data/canvas.ts b/packages/editor-ui/src/__tests__/data/canvas.ts index b61bf82c1d..f8a58b263a 100644 --- a/packages/editor-ui/src/__tests__/data/canvas.ts +++ b/packages/editor-ui/src/__tests__/data/canvas.ts @@ -14,6 +14,7 @@ import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types'; import { NodeConnectionType } from 'n8n-workflow'; import type { EventBus } from 'n8n-design-system'; import { createEventBus } from 'n8n-design-system'; +import type { ViewportTransform } from '@vue-flow/core'; export function createCanvasNodeData({ id = 'node', @@ -91,16 +92,22 @@ export function createCanvasNodeProps({ } export function createCanvasProvide({ + initialized = true, isExecuting = false, connectingHandle = undefined, + viewport = { x: 0, y: 0, zoom: 1 }, }: { + initialized?: boolean; isExecuting?: boolean; connectingHandle?: ConnectStartEvent; + viewport?: ViewportTransform; } = {}) { return { [String(CanvasKey)]: { + initialized: ref(initialized), isExecuting: ref(isExecuting), connectingHandle: ref(connectingHandle), + viewport: ref(viewport), } satisfies CanvasInjectionData, }; } diff --git a/packages/editor-ui/src/__tests__/render.ts b/packages/editor-ui/src/__tests__/render.ts index 3bc310f790..08ef303f4f 100644 --- a/packages/editor-ui/src/__tests__/render.ts +++ b/packages/editor-ui/src/__tests__/render.ts @@ -80,6 +80,10 @@ export function createComponentRenderer( global: { ...defaultOptions.global, ...options.global, + provide: { + ...defaultOptions.global?.provide, + ...options.global?.provide, + }, }, }, ); diff --git a/packages/editor-ui/src/components/canvas/Canvas.vue b/packages/editor-ui/src/components/canvas/Canvas.vue index 254f9c705c..ed4012c195 100644 --- a/packages/editor-ui/src/components/canvas/Canvas.vue +++ b/packages/editor-ui/src/components/canvas/Canvas.vue @@ -6,13 +6,7 @@ import type { CanvasEventBusEvents, ConnectStartEvent, } from '@/types'; -import type { - Connection, - XYPosition, - ViewportTransform, - NodeDragEvent, - GraphNode, -} from '@vue-flow/core'; +import type { Connection, XYPosition, NodeDragEvent, GraphNode } from '@vue-flow/core'; import { useVueFlow, VueFlow, PanelPosition, MarkerType } from '@vue-flow/core'; import { Background } from '@vue-flow/background'; import { MiniMap } from '@vue-flow/minimap'; @@ -114,6 +108,7 @@ const { project, nodes: graphNodes, onPaneReady, + onNodesInitialized, findNode, viewport, onEdgeMouseLeave, @@ -431,7 +426,6 @@ function emitWithLastSelectedNode(emitFn: (id: string) => void) { */ const defaultZoom = 1; -const zoom = ref(defaultZoom); const isPaneMoving = ref(false); function getProjectedPosition(event?: Pick) { @@ -469,10 +463,6 @@ async function onResetZoom() { await onZoomTo(defaultZoom); } -function onViewportChange(viewport: ViewportTransform) { - zoom.value = viewport.zoom; -} - function setReadonly(value: boolean) { setInteractive(!value); elementsSelectable.value = true; @@ -589,6 +579,8 @@ function onMinimapMouseLeave() { * Lifecycle */ +const initialized = ref(false); + onMounted(() => { props.eventBus.on('fitView', onFitView); props.eventBus.on('nodes:select', onSelectNodes); @@ -604,6 +596,10 @@ onPaneReady(async () => { isPaneReady.value = true; }); +onNodesInitialized(() => { + initialized.value = true; +}); + watch(() => props.readOnly, setReadonly, { immediate: true, }); @@ -617,6 +613,8 @@ const isExecuting = toRef(props, 'executing'); provide(CanvasKey, { connectingHandle, isExecuting, + initialized, + viewport, }); @@ -644,7 +642,6 @@ provide(CanvasKey, { @connect-end="onConnectEnd" @pane-click="onClickPane" @contextmenu="onOpenContextMenu" - @viewport-change="onViewportChange" @move-start="onPaneMoveStart" @move-end="onPaneMoveEnd" @node-drag-stop="onNodeDragStop" @@ -717,7 +714,7 @@ provide(CanvasKey, { :position="controlsPosition" :show-interactive="false" :show-bug-reporting-button="showBugReportingButton" - :zoom="zoom" + :zoom="viewport.zoom" @zoom-to-fit="onFitView" @zoom-in="onZoomIn" @zoom-out="onZoomOut" diff --git a/packages/editor-ui/src/components/canvas/elements/edges/CanvasConnectionLine.test.ts b/packages/editor-ui/src/components/canvas/elements/edges/CanvasConnectionLine.test.ts index 56ad3d6d89..8b99a45090 100644 --- a/packages/editor-ui/src/components/canvas/elements/edges/CanvasConnectionLine.test.ts +++ b/packages/editor-ui/src/components/canvas/elements/edges/CanvasConnectionLine.test.ts @@ -4,6 +4,7 @@ import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import type { ConnectionLineProps } from '@vue-flow/core'; import { Position } from '@vue-flow/core'; +import { createCanvasProvide } from '@/__tests__/data'; const DEFAULT_PROPS = { sourceX: 0, @@ -15,6 +16,11 @@ const DEFAULT_PROPS = { } satisfies Partial; const renderComponent = createComponentRenderer(CanvasConnectionLine, { props: DEFAULT_PROPS, + global: { + provide: { + ...createCanvasProvide(), + }, + }, }); beforeEach(() => { diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.test.ts b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.test.ts index 31818cc93a..6b72cdf037 100644 --- a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.test.ts +++ b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.test.ts @@ -3,7 +3,7 @@ import { createComponentRenderer } from '@/__tests__/render'; import { createPinia, setActivePinia } from 'pinia'; import { NodeConnectionType } from 'n8n-workflow'; import { fireEvent } from '@testing-library/vue'; -import { createCanvasNodeProps } from '@/__tests__/data'; +import { createCanvasNodeProps, createCanvasProvide } from '@/__tests__/data'; vi.mock('@/stores/nodeTypes.store', () => ({ useNodeTypesStore: vi.fn(() => ({ @@ -19,7 +19,14 @@ beforeEach(() => { const pinia = createPinia(); setActivePinia(pinia); - renderComponent = createComponentRenderer(CanvasNode, { pinia }); + renderComponent = createComponentRenderer(CanvasNode, { + pinia, + global: { + provide: { + ...createCanvasProvide(), + }, + }, + }); }); describe('CanvasNode', () => { diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.test.ts b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.test.ts index 28ccc20379..ae36476792 100644 --- a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.test.ts +++ b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.test.ts @@ -1,6 +1,6 @@ import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue'; import { createComponentRenderer } from '@/__tests__/render'; -import { createCanvasNodeProvide } from '@/__tests__/data'; +import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data'; import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import { CanvasNodeRenderType } from '@/types'; @@ -17,6 +17,7 @@ describe('CanvasNodeRenderer', () => { const { getByTestId } = renderComponent({ global: { provide: { + ...createCanvasProvide(), ...createCanvasNodeProvide(), }, }, @@ -29,6 +30,7 @@ describe('CanvasNodeRenderer', () => { const { getByTestId } = renderComponent({ global: { provide: { + ...createCanvasProvide(), ...createCanvasNodeProvide({ data: { render: { @@ -48,6 +50,7 @@ describe('CanvasNodeRenderer', () => { const { getByTestId } = renderComponent({ global: { provide: { + ...createCanvasProvide(), ...createCanvasNodeProvide({ data: { render: { diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.test.ts b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.test.ts index 422168c2ab..256720c879 100644 --- a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.test.ts +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.test.ts @@ -1,12 +1,18 @@ import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue'; import { createComponentRenderer } from '@/__tests__/render'; import { NodeConnectionType } from 'n8n-workflow'; -import { createCanvasNodeProvide } from '@/__tests__/data'; +import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data'; import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types'; -const renderComponent = createComponentRenderer(CanvasNodeDefault); +const renderComponent = createComponentRenderer(CanvasNodeDefault, { + global: { + provide: { + ...createCanvasProvide(), + }, + }, +}); beforeEach(() => { const pinia = createTestingPinia(); diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue index 967a80e6b9..70a9005adc 100644 --- a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue @@ -1,13 +1,11 @@ + + + + diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeTriggerIcon.test.ts b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeTriggerIcon.test.ts new file mode 100644 index 0000000000..2fadcc0aee --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeTriggerIcon.test.ts @@ -0,0 +1,35 @@ +import CanvasNodeTriggerIcon from './CanvasNodeTriggerIcon.vue'; +import { createComponentRenderer } from '@/__tests__/render'; + +vi.mock('@/composables/useI18n', () => ({ + useI18n: vi.fn(() => ({ + baseText: vi.fn().mockReturnValue('This is a trigger node'), + })), +})); + +const renderComponent = createComponentRenderer(CanvasNodeTriggerIcon, { + global: { + stubs: { + FontAwesomeIcon: true, + }, + }, +}); + +describe('CanvasNodeTriggerIcon', () => { + it('should render trigger icon with tooltip', () => { + const { container } = renderComponent(); + + expect(container.querySelector('.triggerIcon')).toBeInTheDocument(); + + const icon = container.querySelector('font-awesome-icon-stub'); + expect(icon).toBeInTheDocument(); + expect(icon?.getAttribute('icon')).toBe('bolt'); + expect(icon?.getAttribute('size')).toBe('lg'); + }); + + it('should render tooltip with correct content', () => { + const { getByText } = renderComponent(); + + expect(getByText('This is a trigger node')).toBeInTheDocument(); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeTriggerIcon.vue b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeTriggerIcon.vue new file mode 100644 index 0000000000..e37f384dba --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeTriggerIcon.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/packages/editor-ui/src/composables/useCanvas.ts b/packages/editor-ui/src/composables/useCanvas.ts index 57031a233b..dfa8f63ad4 100644 --- a/packages/editor-ui/src/composables/useCanvas.ts +++ b/packages/editor-ui/src/composables/useCanvas.ts @@ -1,14 +1,6 @@ -import { computed, inject } from 'vue'; import { CanvasKey } from '@/constants'; +import { injectStrict } from '@/utils/injectStrict'; export function useCanvas() { - const canvas = inject(CanvasKey); - - const connectingHandle = computed(() => canvas?.connectingHandle.value); - const isExecuting = computed(() => canvas?.isExecuting.value); - - return { - isExecuting, - connectingHandle, - }; + return injectStrict(CanvasKey); } diff --git a/packages/editor-ui/src/composables/useCanvasMapping.ts b/packages/editor-ui/src/composables/useCanvasMapping.ts index 98a32cfd12..036a2a2ad1 100644 --- a/packages/editor-ui/src/composables/useCanvasMapping.ts +++ b/packages/editor-ui/src/composables/useCanvasMapping.ts @@ -33,6 +33,7 @@ import type { ExecutionSummary, IConnections, INodeExecutionData, + INodeTypeDescription, ITaskData, Workflow, } from 'n8n-workflow'; @@ -48,6 +49,7 @@ import { import { sanitizeHtml } from '@/utils/htmlUtils'; import { MarkerType } from '@vue-flow/core'; import { useNodeHelpers } from './useNodeHelpers'; +import { getTriggerNodeServiceName } from '@/utils/nodeTypesUtils'; export function useCanvasMapping({ nodes, @@ -86,7 +88,7 @@ export function useCanvasMapping({ return { type: CanvasNodeRenderType.Default, options: { - trigger: nodeTypesStore.isTriggerNode(node.type), + trigger: isTriggerNodeById.value[node.id], configuration: nodeTypesStore.isConfigNode(workflowObject.value, node, node.type), configurable: nodeTypesStore.isConfigurableNode(workflowObject.value, node, node.type), inputs: { @@ -95,6 +97,7 @@ export function useCanvasMapping({ outputs: { labelSize: nodeOutputLabelSizeById.value[node.id], }, + tooltip: nodeTooltipById.value[node.id], }, }; } @@ -117,10 +120,34 @@ export function useCanvasMapping({ }, {}) ?? {}, ); + const nodeTypeDescriptionByNodeId = computed(() => + nodes.value.reduce>((acc, node) => { + acc[node.id] = nodeTypesStore.getNodeType(node.type, node.typeVersion); + return acc; + }, {}), + ); + + const isTriggerNodeById = computed(() => + nodes.value.reduce>((acc, node) => { + acc[node.id] = nodeTypesStore.isTriggerNode(node.type); + return acc; + }, {}), + ); + + const activeTriggerNodeCount = computed( + () => + nodes.value.filter( + (node) => + nodeTypeDescriptionByNodeId.value[node.id]?.eventTriggerDescription !== '' && + isTriggerNodeById.value[node.id] && + !node.disabled, + ).length, + ); + const nodeSubtitleById = computed(() => { return nodes.value.reduce>((acc, node) => { try { - const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion); + const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id]; if (!nodeTypeDescription) { return acc; } @@ -140,7 +167,7 @@ export function useCanvasMapping({ const nodeInputsById = computed(() => nodes.value.reduce>((acc, node) => { - const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion); + const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id]; const workflowObjectNode = workflowObject.value.getNode(node.name); acc[node.id] = @@ -203,7 +230,7 @@ export function useCanvasMapping({ const nodeOutputsById = computed(() => nodes.value.reduce>((acc, node) => { - const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion); + const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id]; const workflowObjectNode = workflowObject.value.getNode(node.name); acc[node.id] = @@ -229,6 +256,37 @@ export function useCanvasMapping({ }, {}), ); + const nodeTooltipById = computed(() => + nodes.value.reduce>((acc, node) => { + const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id]; + if (nodeTypeDescription && isTriggerNodeById.value[node.id]) { + if ( + activeTriggerNodeCount.value !== 1 || + !workflowsStore.isWorkflowRunning || + !['new', 'unknown', 'waiting'].includes(nodeExecutionStatusById.value[node.id]) + ) { + return acc; + } + + if ('eventTriggerDescription' in nodeTypeDescription) { + const nodeName = i18n.shortNodeType(nodeTypeDescription.name); + const { eventTriggerDescription } = nodeTypeDescription; + acc[node.id] = i18n + .nodeText() + .eventTriggerDescription(nodeName, eventTriggerDescription ?? ''); + } else { + acc[node.id] = i18n.baseText('node.waitingForYouToCreateAnEventIn', { + interpolate: { + nodeType: nodeTypeDescription ? getTriggerNodeServiceName(nodeTypeDescription) : '', + }, + }); + } + } + + return acc; + }, {}), + ); + const nodeExecutionRunningById = computed(() => nodes.value.reduce>((acc, node) => { acc[node.id] = workflowsStore.isNodeExecuting(node.name); diff --git a/packages/editor-ui/src/types/canvas.ts b/packages/editor-ui/src/types/canvas.ts index 96d24ea453..ad8d51ba8a 100644 --- a/packages/editor-ui/src/types/canvas.ts +++ b/packages/editor-ui/src/types/canvas.ts @@ -5,7 +5,14 @@ import type { IConnection, NodeConnectionType, } from 'n8n-workflow'; -import type { DefaultEdge, Node, NodeProps, Position, OnConnectStartParams } from '@vue-flow/core'; +import type { + DefaultEdge, + Node, + NodeProps, + Position, + OnConnectStartParams, + ViewportTransform, +} from '@vue-flow/core'; import type { IExecutionResponse, INodeUi } from '@/Interface'; import type { ComputedRef, Ref } from 'vue'; import type { PartialBy } from '@/utils/typeHelpers'; @@ -59,6 +66,7 @@ export type CanvasNodeDefaultRender = { outputs: { labelSize: CanvasNodeDefaultRenderLabelSize; }; + tooltip?: string; }>; }; @@ -135,8 +143,10 @@ export type CanvasConnectionCreateData = { }; export interface CanvasInjectionData { + initialized: Ref; isExecuting: Ref; connectingHandle: Ref; + viewport: Ref; } export type CanvasNodeEventBusEvents = { diff --git a/packages/editor-ui/src/utils/injectStrict.test.ts b/packages/editor-ui/src/utils/injectStrict.test.ts new file mode 100644 index 0000000000..d8051d7a96 --- /dev/null +++ b/packages/editor-ui/src/utils/injectStrict.test.ts @@ -0,0 +1,35 @@ +import { injectStrict } from '@/utils/injectStrict'; +import type { InjectionKey } from 'vue'; +import { inject } from 'vue'; + +vi.mock('vue', async () => { + const original = await vi.importActual('vue'); + return { + ...original, + inject: vi.fn(), + }; +}); + +describe('injectStrict', () => { + it('should return the injected value when it exists', () => { + const key = Symbol('testKey') as InjectionKey; + const value = 'testValue'; + vi.mocked(inject).mockReturnValueOnce(value); + const result = injectStrict(key); + expect(result).toBe(value); + }); + + it('should return the fallback value when the injected value does not exist', () => { + const key = Symbol('testKey') as InjectionKey; + const fallback = 'fallbackValue'; + vi.mocked(inject).mockReturnValueOnce(fallback); + const result = injectStrict(key, fallback); + expect(result).toBe(fallback); + }); + + it('should throw an error when the injected value does not exist and no fallback is provided', () => { + const key = Symbol('testKey') as InjectionKey; + vi.mocked(inject).mockReturnValueOnce(undefined); + expect(() => injectStrict(key)).toThrowError(`Could not resolve ${key.description}`); + }); +}); diff --git a/packages/editor-ui/src/utils/injectStrict.ts b/packages/editor-ui/src/utils/injectStrict.ts new file mode 100644 index 0000000000..d232927840 --- /dev/null +++ b/packages/editor-ui/src/utils/injectStrict.ts @@ -0,0 +1,10 @@ +import type { InjectionKey } from 'vue'; +import { inject } from 'vue'; + +export function injectStrict(key: InjectionKey, fallback?: T) { + const resolved = inject(key, fallback); + if (!resolved) { + throw new Error(`Could not resolve ${key.description}`); + } + return resolved; +}