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 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,
};
}

View file

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

View file

@ -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<MouseEvent, 'clientX' | 'clientY'>) {
@ -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,
});
</script>
@ -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"

View file

@ -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<ConnectionLineProps>;
const renderComponent = createComponentRenderer(CanvasConnectionLine, {
props: DEFAULT_PROPS,
global: {
provide: {
...createCanvasProvide(),
},
},
});
beforeEach(() => {

View file

@ -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', () => {

View file

@ -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: {

View file

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

View file

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

View file

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

View file

@ -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<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(() => {
return nodes.value.reduce<Record<string, string>>((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<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);
acc[node.id] =
@ -203,7 +230,7 @@ export function useCanvasMapping({
const nodeOutputsById = computed(() =>
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);
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(() =>
nodes.value.reduce<Record<string, boolean>>((acc, node) => {
acc[node.id] = workflowsStore.isNodeExecuting(node.name);

View file

@ -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<boolean>;
isExecuting: Ref<boolean | undefined>;
connectingHandle: Ref<ConnectStartEvent | undefined>;
viewport: Ref<ViewportTransform>;
}
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;
}