fix(editor): Add missing trigger waiting tooltip on new canvas (#11918)

This commit is contained in:
Alex Grozav 2024-11-27 14:05:00 +02:00 committed by GitHub
parent f91fecfe0d
commit a8df221bfb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 352 additions and 54 deletions

View file

@ -14,6 +14,7 @@ import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import type { EventBus } from 'n8n-design-system'; import type { EventBus } from 'n8n-design-system';
import { createEventBus } from 'n8n-design-system'; import { createEventBus } from 'n8n-design-system';
import type { ViewportTransform } from '@vue-flow/core';
export function createCanvasNodeData({ export function createCanvasNodeData({
id = 'node', id = 'node',
@ -91,16 +92,22 @@ export function createCanvasNodeProps({
} }
export function createCanvasProvide({ export function createCanvasProvide({
initialized = true,
isExecuting = false, isExecuting = false,
connectingHandle = undefined, connectingHandle = undefined,
viewport = { x: 0, y: 0, zoom: 1 },
}: { }: {
initialized?: boolean;
isExecuting?: boolean; isExecuting?: boolean;
connectingHandle?: ConnectStartEvent; connectingHandle?: ConnectStartEvent;
viewport?: ViewportTransform;
} = {}) { } = {}) {
return { return {
[String(CanvasKey)]: { [String(CanvasKey)]: {
initialized: ref(initialized),
isExecuting: ref(isExecuting), isExecuting: ref(isExecuting),
connectingHandle: ref(connectingHandle), connectingHandle: ref(connectingHandle),
viewport: ref(viewport),
} satisfies CanvasInjectionData, } satisfies CanvasInjectionData,
}; };
} }

View file

@ -80,6 +80,10 @@ export function createComponentRenderer(
global: { global: {
...defaultOptions.global, ...defaultOptions.global,
...options.global, ...options.global,
provide: {
...defaultOptions.global?.provide,
...options.global?.provide,
},
}, },
}, },
); );

View file

@ -6,13 +6,7 @@ import type {
CanvasEventBusEvents, CanvasEventBusEvents,
ConnectStartEvent, ConnectStartEvent,
} from '@/types'; } from '@/types';
import type { import type { Connection, XYPosition, NodeDragEvent, GraphNode } from '@vue-flow/core';
Connection,
XYPosition,
ViewportTransform,
NodeDragEvent,
GraphNode,
} from '@vue-flow/core';
import { useVueFlow, VueFlow, PanelPosition, MarkerType } from '@vue-flow/core'; import { useVueFlow, VueFlow, PanelPosition, MarkerType } from '@vue-flow/core';
import { Background } from '@vue-flow/background'; import { Background } from '@vue-flow/background';
import { MiniMap } from '@vue-flow/minimap'; import { MiniMap } from '@vue-flow/minimap';
@ -114,6 +108,7 @@ const {
project, project,
nodes: graphNodes, nodes: graphNodes,
onPaneReady, onPaneReady,
onNodesInitialized,
findNode, findNode,
viewport, viewport,
onEdgeMouseLeave, onEdgeMouseLeave,
@ -431,7 +426,6 @@ function emitWithLastSelectedNode(emitFn: (id: string) => void) {
*/ */
const defaultZoom = 1; const defaultZoom = 1;
const zoom = ref(defaultZoom);
const isPaneMoving = ref(false); const isPaneMoving = ref(false);
function getProjectedPosition(event?: Pick<MouseEvent, 'clientX' | 'clientY'>) { function getProjectedPosition(event?: Pick<MouseEvent, 'clientX' | 'clientY'>) {
@ -469,10 +463,6 @@ async function onResetZoom() {
await onZoomTo(defaultZoom); await onZoomTo(defaultZoom);
} }
function onViewportChange(viewport: ViewportTransform) {
zoom.value = viewport.zoom;
}
function setReadonly(value: boolean) { function setReadonly(value: boolean) {
setInteractive(!value); setInteractive(!value);
elementsSelectable.value = true; elementsSelectable.value = true;
@ -589,6 +579,8 @@ function onMinimapMouseLeave() {
* Lifecycle * Lifecycle
*/ */
const initialized = ref(false);
onMounted(() => { onMounted(() => {
props.eventBus.on('fitView', onFitView); props.eventBus.on('fitView', onFitView);
props.eventBus.on('nodes:select', onSelectNodes); props.eventBus.on('nodes:select', onSelectNodes);
@ -604,6 +596,10 @@ onPaneReady(async () => {
isPaneReady.value = true; isPaneReady.value = true;
}); });
onNodesInitialized(() => {
initialized.value = true;
});
watch(() => props.readOnly, setReadonly, { watch(() => props.readOnly, setReadonly, {
immediate: true, immediate: true,
}); });
@ -617,6 +613,8 @@ const isExecuting = toRef(props, 'executing');
provide(CanvasKey, { provide(CanvasKey, {
connectingHandle, connectingHandle,
isExecuting, isExecuting,
initialized,
viewport,
}); });
</script> </script>
@ -644,7 +642,6 @@ provide(CanvasKey, {
@connect-end="onConnectEnd" @connect-end="onConnectEnd"
@pane-click="onClickPane" @pane-click="onClickPane"
@contextmenu="onOpenContextMenu" @contextmenu="onOpenContextMenu"
@viewport-change="onViewportChange"
@move-start="onPaneMoveStart" @move-start="onPaneMoveStart"
@move-end="onPaneMoveEnd" @move-end="onPaneMoveEnd"
@node-drag-stop="onNodeDragStop" @node-drag-stop="onNodeDragStop"
@ -717,7 +714,7 @@ provide(CanvasKey, {
:position="controlsPosition" :position="controlsPosition"
:show-interactive="false" :show-interactive="false"
:show-bug-reporting-button="showBugReportingButton" :show-bug-reporting-button="showBugReportingButton"
:zoom="zoom" :zoom="viewport.zoom"
@zoom-to-fit="onFitView" @zoom-to-fit="onFitView"
@zoom-in="onZoomIn" @zoom-in="onZoomIn"
@zoom-out="onZoomOut" @zoom-out="onZoomOut"

View file

@ -4,6 +4,7 @@ import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import type { ConnectionLineProps } from '@vue-flow/core'; import type { ConnectionLineProps } from '@vue-flow/core';
import { Position } from '@vue-flow/core'; import { Position } from '@vue-flow/core';
import { createCanvasProvide } from '@/__tests__/data';
const DEFAULT_PROPS = { const DEFAULT_PROPS = {
sourceX: 0, sourceX: 0,
@ -15,6 +16,11 @@ const DEFAULT_PROPS = {
} satisfies Partial<ConnectionLineProps>; } satisfies Partial<ConnectionLineProps>;
const renderComponent = createComponentRenderer(CanvasConnectionLine, { const renderComponent = createComponentRenderer(CanvasConnectionLine, {
props: DEFAULT_PROPS, props: DEFAULT_PROPS,
global: {
provide: {
...createCanvasProvide(),
},
},
}); });
beforeEach(() => { beforeEach(() => {

View file

@ -3,7 +3,7 @@ import { createComponentRenderer } from '@/__tests__/render';
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import { fireEvent } from '@testing-library/vue'; import { fireEvent } from '@testing-library/vue';
import { createCanvasNodeProps } from '@/__tests__/data'; import { createCanvasNodeProps, createCanvasProvide } from '@/__tests__/data';
vi.mock('@/stores/nodeTypes.store', () => ({ vi.mock('@/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({ useNodeTypesStore: vi.fn(() => ({
@ -19,7 +19,14 @@ beforeEach(() => {
const pinia = createPinia(); const pinia = createPinia();
setActivePinia(pinia); setActivePinia(pinia);
renderComponent = createComponentRenderer(CanvasNode, { pinia }); renderComponent = createComponentRenderer(CanvasNode, {
pinia,
global: {
provide: {
...createCanvasProvide(),
},
},
});
}); });
describe('CanvasNode', () => { describe('CanvasNode', () => {

View file

@ -1,6 +1,6 @@
import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue'; import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data'; import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import { CanvasNodeRenderType } from '@/types'; import { CanvasNodeRenderType } from '@/types';
@ -17,6 +17,7 @@ describe('CanvasNodeRenderer', () => {
const { getByTestId } = renderComponent({ const { getByTestId } = renderComponent({
global: { global: {
provide: { provide: {
...createCanvasProvide(),
...createCanvasNodeProvide(), ...createCanvasNodeProvide(),
}, },
}, },
@ -29,6 +30,7 @@ describe('CanvasNodeRenderer', () => {
const { getByTestId } = renderComponent({ const { getByTestId } = renderComponent({
global: { global: {
provide: { provide: {
...createCanvasProvide(),
...createCanvasNodeProvide({ ...createCanvasNodeProvide({
data: { data: {
render: { render: {
@ -48,6 +50,7 @@ describe('CanvasNodeRenderer', () => {
const { getByTestId } = renderComponent({ const { getByTestId } = renderComponent({
global: { global: {
provide: { provide: {
...createCanvasProvide(),
...createCanvasNodeProvide({ ...createCanvasNodeProvide({
data: { data: {
render: { render: {

View file

@ -1,12 +1,18 @@
import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue'; import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import { createCanvasNodeProvide } from '@/__tests__/data'; import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types'; import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
const renderComponent = createComponentRenderer(CanvasNodeDefault); const renderComponent = createComponentRenderer(CanvasNodeDefault, {
global: {
provide: {
...createCanvasProvide(),
},
},
});
beforeEach(() => { beforeEach(() => {
const pinia = createTestingPinia(); const pinia = createTestingPinia();

View file

@ -1,13 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, useCssModule } from 'vue'; import { computed, ref, useCssModule, watch } from 'vue';
import { useNodeConnections } from '@/composables/useNodeConnections'; import { useNodeConnections } from '@/composables/useNodeConnections';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import CanvasNodeDisabledStrikeThrough from './parts/CanvasNodeDisabledStrikeThrough.vue';
import CanvasNodeStatusIcons from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue';
import { useCanvasNode } from '@/composables/useCanvasNode'; import { useCanvasNode } from '@/composables/useCanvasNode';
import { NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants'; import { NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
import { N8nTooltip } from 'n8n-design-system';
import type { CanvasNodeDefaultRender } from '@/types'; import type { CanvasNodeDefaultRender } from '@/types';
import { useCanvas } from '@/composables/useCanvas';
const $style = useCssModule(); const $style = useCssModule();
const i18n = useI18n(); const i18n = useI18n();
@ -16,6 +14,7 @@ const emit = defineEmits<{
'open:contextmenu': [event: MouseEvent]; 'open:contextmenu': [event: MouseEvent];
}>(); }>();
const { initialized, viewport } = useCanvas();
const { const {
label, label,
subtitle, subtitle,
@ -105,6 +104,21 @@ const isStrikethroughVisible = computed(() => {
return isDisabled.value && isSingleMainInputNode && isSingleMainOutputNode; return isDisabled.value && isSingleMainInputNode && isSingleMainOutputNode;
}); });
const showTooltip = ref(false);
watch(initialized, () => {
if (initialized.value) {
showTooltip.value = true;
}
});
watch(viewport, () => {
showTooltip.value = false;
setTimeout(() => {
showTooltip.value = true;
}, 0);
});
function openContextMenu(event: MouseEvent) { function openContextMenu(event: MouseEvent) {
emit('open:contextmenu', event); emit('open:contextmenu', event);
} }
@ -112,15 +126,9 @@ function openContextMenu(event: MouseEvent) {
<template> <template>
<div :class="classes" :style="styles" :data-test-id="dataTestId" @contextmenu="openContextMenu"> <div :class="classes" :style="styles" :data-test-id="dataTestId" @contextmenu="openContextMenu">
<CanvasNodeTooltip v-if="renderOptions.tooltip" :visible="showTooltip" />
<slot /> <slot />
<N8nTooltip v-if="renderOptions.trigger" placement="bottom"> <CanvasNodeTriggerIcon v-if="renderOptions.trigger" />
<template #content>
<span v-n8n-html="i18n.baseText('node.thisIsATriggerNode')" />
</template>
<div :class="$style.triggerIcon">
<FontAwesomeIcon icon="bolt" size="lg" />
</div>
</N8nTooltip>
<CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" /> <CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" />
<CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" /> <CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" />
<div :class="$style.description"> <div :class="$style.description">
@ -152,6 +160,7 @@ function openContextMenu(event: MouseEvent) {
--trigger-node--border-radius: 36px; --trigger-node--border-radius: 36px;
--canvas-node--status-icons-offset: var(--spacing-2xs); --canvas-node--status-icons-offset: var(--spacing-2xs);
position: relative;
height: var(--canvas-node--height); height: var(--canvas-node--height);
width: var(--canvas-node--width); width: var(--canvas-node--width);
display: flex; display: flex;
@ -298,12 +307,4 @@ function openContextMenu(event: MouseEvent) {
bottom: var(--canvas-node--status-icons-offset); bottom: var(--canvas-node--status-icons-offset);
right: var(--canvas-node--status-icons-offset); right: var(--canvas-node--status-icons-offset);
} }
.triggerIcon {
position: absolute;
right: 100%;
margin: auto;
color: var(--color-primary);
padding: var(--spacing-2xs);
}
</style> </style>

View file

@ -6,6 +6,7 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
data-test-id="canvas-configurable-node" data-test-id="canvas-configurable-node"
style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
> >
<!--v-if-->
<!--v-if--> <!--v-if-->
@ -35,6 +36,7 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
data-test-id="canvas-configurable-node" data-test-id="canvas-configurable-node"
style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
> >
<!--v-if-->
<!--v-if--> <!--v-if-->
@ -64,6 +66,7 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
data-test-id="canvas-configuration-node" data-test-id="canvas-configuration-node"
style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
> >
<!--v-if-->
<!--v-if--> <!--v-if-->
@ -93,6 +96,7 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
data-test-id="canvas-default-node" data-test-id="canvas-default-node"
style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
> >
<!--v-if-->
<!--v-if--> <!--v-if-->
@ -122,6 +126,7 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
data-test-id="canvas-trigger-node" data-test-id="canvas-trigger-node"
style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
> >
<!--v-if-->

View file

@ -0,0 +1,54 @@
import CanvasNodeTooltip from './CanvasNodeTooltip.vue';
import { createComponentRenderer } from '@/__tests__/render';
import type { CanvasNodeDefaultRender } from '@/types';
import { createCanvasNodeProvide } from '@/__tests__/data';
import { waitFor } from '@testing-library/vue';
const renderComponent = createComponentRenderer(CanvasNodeTooltip);
describe('CanvasNodeTooltip', () => {
describe('rendering', () => {
it('should render tooltip when tooltip option is provided', async () => {
const { container, getByText } = renderComponent({
props: {
visible: true,
},
global: {
provide: createCanvasNodeProvide({
data: {
render: {
options: {
tooltip: 'Test tooltip text',
},
} as CanvasNodeDefaultRender,
},
}),
},
});
expect(getByText('Test tooltip text')).toBeInTheDocument();
await waitFor(() => expect(container.querySelector('.el-popper')).toBeVisible());
});
it('should not render tooltip when tooltip option is not provided', () => {
const { container } = renderComponent({
props: {
visible: false,
},
global: {
provide: createCanvasNodeProvide({
data: {
render: {
options: {
tooltip: 'Test tooltip text',
},
} as CanvasNodeDefaultRender,
},
}),
},
});
expect(container.querySelector('.el-popper')).not.toBeVisible();
});
});
});

View file

@ -0,0 +1,42 @@
<script lang="ts" setup>
import { useCanvasNode } from '@/composables/useCanvasNode';
import { computed } from 'vue';
import type { CanvasNodeDefaultRender } from '@/types';
defineProps<{
visible: boolean;
}>();
const { render } = useCanvasNode();
const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']);
</script>
<template>
<N8nTooltip
placement="top"
:show-after="500"
:visible="true"
:teleported="false"
:popper-class="$style.popper"
>
<template #content>
{{ renderOptions.tooltip }}
</template>
<div :class="$style.tooltipTrigger" />
</N8nTooltip>
</template>
<style lang="scss" module>
.tooltipTrigger {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 1px;
}
.popper {
white-space: nowrap;
}
</style>

View file

@ -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();
});
});

View file

@ -0,0 +1,26 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
const i18n = useI18n();
</script>
<template>
<N8nTooltip placement="bottom">
<template #content>
<span v-n8n-html="i18n.baseText('node.thisIsATriggerNode')" />
</template>
<div :class="$style.triggerIcon">
<FontAwesomeIcon icon="bolt" size="lg" />
</div>
</N8nTooltip>
</template>
<style lang="scss" module>
.triggerIcon {
position: absolute;
right: 100%;
margin: auto;
color: var(--color-primary);
padding: var(--spacing-2xs);
}
</style>

View file

@ -1,14 +1,6 @@
import { computed, inject } from 'vue';
import { CanvasKey } from '@/constants'; import { CanvasKey } from '@/constants';
import { injectStrict } from '@/utils/injectStrict';
export function useCanvas() { export function useCanvas() {
const canvas = inject(CanvasKey); return injectStrict(CanvasKey);
const connectingHandle = computed(() => canvas?.connectingHandle.value);
const isExecuting = computed(() => canvas?.isExecuting.value);
return {
isExecuting,
connectingHandle,
};
} }

View file

@ -33,6 +33,7 @@ import type {
ExecutionSummary, ExecutionSummary,
IConnections, IConnections,
INodeExecutionData, INodeExecutionData,
INodeTypeDescription,
ITaskData, ITaskData,
Workflow, Workflow,
} from 'n8n-workflow'; } from 'n8n-workflow';
@ -48,6 +49,7 @@ import {
import { sanitizeHtml } from '@/utils/htmlUtils'; import { sanitizeHtml } from '@/utils/htmlUtils';
import { MarkerType } from '@vue-flow/core'; import { MarkerType } from '@vue-flow/core';
import { useNodeHelpers } from './useNodeHelpers'; import { useNodeHelpers } from './useNodeHelpers';
import { getTriggerNodeServiceName } from '@/utils/nodeTypesUtils';
export function useCanvasMapping({ export function useCanvasMapping({
nodes, nodes,
@ -86,7 +88,7 @@ export function useCanvasMapping({
return { return {
type: CanvasNodeRenderType.Default, type: CanvasNodeRenderType.Default,
options: { options: {
trigger: nodeTypesStore.isTriggerNode(node.type), trigger: isTriggerNodeById.value[node.id],
configuration: nodeTypesStore.isConfigNode(workflowObject.value, node, node.type), configuration: nodeTypesStore.isConfigNode(workflowObject.value, node, node.type),
configurable: nodeTypesStore.isConfigurableNode(workflowObject.value, node, node.type), configurable: nodeTypesStore.isConfigurableNode(workflowObject.value, node, node.type),
inputs: { inputs: {
@ -95,6 +97,7 @@ export function useCanvasMapping({
outputs: { outputs: {
labelSize: nodeOutputLabelSizeById.value[node.id], labelSize: nodeOutputLabelSizeById.value[node.id],
}, },
tooltip: nodeTooltipById.value[node.id],
}, },
}; };
} }
@ -117,10 +120,34 @@ export function useCanvasMapping({
}, {}) ?? {}, }, {}) ?? {},
); );
const nodeTypeDescriptionByNodeId = computed(() =>
nodes.value.reduce<Record<string, INodeTypeDescription | null>>((acc, node) => {
acc[node.id] = nodeTypesStore.getNodeType(node.type, node.typeVersion);
return acc;
}, {}),
);
const isTriggerNodeById = computed(() =>
nodes.value.reduce<Record<string, boolean>>((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(() => { const nodeSubtitleById = computed(() => {
return nodes.value.reduce<Record<string, string>>((acc, node) => { return nodes.value.reduce<Record<string, string>>((acc, node) => {
try { try {
const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion); const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id];
if (!nodeTypeDescription) { if (!nodeTypeDescription) {
return acc; return acc;
} }
@ -140,7 +167,7 @@ export function useCanvasMapping({
const nodeInputsById = computed(() => const nodeInputsById = computed(() =>
nodes.value.reduce<Record<string, CanvasConnectionPort[]>>((acc, node) => { nodes.value.reduce<Record<string, CanvasConnectionPort[]>>((acc, node) => {
const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion); const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id];
const workflowObjectNode = workflowObject.value.getNode(node.name); const workflowObjectNode = workflowObject.value.getNode(node.name);
acc[node.id] = acc[node.id] =
@ -203,7 +230,7 @@ export function useCanvasMapping({
const nodeOutputsById = computed(() => const nodeOutputsById = computed(() =>
nodes.value.reduce<Record<string, CanvasConnectionPort[]>>((acc, node) => { nodes.value.reduce<Record<string, CanvasConnectionPort[]>>((acc, node) => {
const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion); const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id];
const workflowObjectNode = workflowObject.value.getNode(node.name); const workflowObjectNode = workflowObject.value.getNode(node.name);
acc[node.id] = acc[node.id] =
@ -229,6 +256,37 @@ export function useCanvasMapping({
}, {}), }, {}),
); );
const nodeTooltipById = computed(() =>
nodes.value.reduce<Record<string, string | undefined>>((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(() => const nodeExecutionRunningById = computed(() =>
nodes.value.reduce<Record<string, boolean>>((acc, node) => { nodes.value.reduce<Record<string, boolean>>((acc, node) => {
acc[node.id] = workflowsStore.isNodeExecuting(node.name); acc[node.id] = workflowsStore.isNodeExecuting(node.name);

View file

@ -5,7 +5,14 @@ import type {
IConnection, IConnection,
NodeConnectionType, NodeConnectionType,
} from 'n8n-workflow'; } 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 { IExecutionResponse, INodeUi } from '@/Interface';
import type { ComputedRef, Ref } from 'vue'; import type { ComputedRef, Ref } from 'vue';
import type { PartialBy } from '@/utils/typeHelpers'; import type { PartialBy } from '@/utils/typeHelpers';
@ -59,6 +66,7 @@ export type CanvasNodeDefaultRender = {
outputs: { outputs: {
labelSize: CanvasNodeDefaultRenderLabelSize; labelSize: CanvasNodeDefaultRenderLabelSize;
}; };
tooltip?: string;
}>; }>;
}; };
@ -135,8 +143,10 @@ export type CanvasConnectionCreateData = {
}; };
export interface CanvasInjectionData { export interface CanvasInjectionData {
initialized: Ref<boolean>;
isExecuting: Ref<boolean | undefined>; isExecuting: Ref<boolean | undefined>;
connectingHandle: Ref<ConnectStartEvent | undefined>; connectingHandle: Ref<ConnectStartEvent | undefined>;
viewport: Ref<ViewportTransform>;
} }
export type CanvasNodeEventBusEvents = { export type CanvasNodeEventBusEvents = {

View file

@ -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<string>;
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<string>;
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<string>;
vi.mocked(inject).mockReturnValueOnce(undefined);
expect(() => injectStrict(key)).toThrowError(`Could not resolve ${key.description}`);
});
});

View file

@ -0,0 +1,10 @@
import type { InjectionKey } from 'vue';
import { inject } from 'vue';
export function injectStrict<T>(key: InjectionKey<T>, fallback?: T) {
const resolved = inject(key, fallback);
if (!resolved) {
throw new Error(`Could not resolve ${key.description}`);
}
return resolved;
}