fix(editor): Ai 668 UI changes to mock nodes modal (no-changelog) (#13899)

Co-authored-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
Raúl Gómez Morales
2025-03-17 09:40:07 +01:00
committed by GitHub
parent c91688d494
commit 4a1e5798ff
5 changed files with 198 additions and 174 deletions

View File

@@ -1,15 +1,16 @@
<script setup lang="ts">
import Canvas from '@/components/canvas/Canvas.vue';
import CanvasNode from '@/components/canvas/elements/nodes/CanvasNode.vue';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { CanvasConnectionPort, CanvasEventBusEvents, CanvasNodeData } from '@/types';
import type { CanvasConnectionPort, CanvasNodeData } from '@/types';
import { N8nButton, N8nHeading, N8nSpinner, N8nText, N8nTooltip } from '@n8n/design-system';
import { useVueFlow } from '@vue-flow/core';
import { N8nTooltip } from '@n8n/design-system';
import { createEventBus } from '@n8n/utils/event-bus';
import { computed, onMounted, ref, useCssModule } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const workflowsStore = useWorkflowsStore();
@@ -21,18 +22,12 @@ const telemetry = useTelemetry();
const { resetWorkspace, initializeWorkspace } = useCanvasOperations({ router });
const eventBus = createEventBus<CanvasEventBusEvents>();
const style = useCssModule();
const uuid = crypto.randomUUID();
const props = defineProps<{
modelValue: Array<{ name: string; id: string }>;
}>();
const emit = defineEmits<{
'update:modelValue': [value: Array<{ name: string; id: string }>];
}>();
type PinnedNode = { name: string; id: string };
const model = defineModel<PinnedNode[]>({ required: true });
const isLoading = ref(true);
const isLoading = ref(false);
const workflowId = computed(() => route.params.name as string);
const testId = computed(() => route.params.testId as string);
@@ -40,10 +35,10 @@ const workflow = computed(() => workflowsStore.getWorkflowById(workflowId.value)
const workflowObject = computed(() => workflowsStore.getCurrentWorkflow(true));
const canvasId = computed(() => `${uuid}-${testId.value}`);
const { onNodesInitialized, fitView, zoomTo } = useVueFlow({ id: canvasId.value });
const nodes = computed(() => {
return workflow.value.nodes ?? [];
const { onNodesInitialized, fitView, zoomTo, onNodeClick, viewport } = useVueFlow({
id: canvasId.value,
});
const nodes = computed(() => workflow.value.nodes ?? []);
const connections = computed(() => workflow.value.connections);
const { nodes: mappedNodes, connections: mappedConnections } = useCanvasMapping({
@@ -51,144 +46,164 @@ const { nodes: mappedNodes, connections: mappedConnections } = useCanvasMapping(
connections,
workflowObject,
});
async function loadData() {
isLoading.value = true;
workflowsStore.resetState();
resetWorkspace();
const loadingPromise = Promise.all([
await Promise.all([
nodeTypesStore.getNodeTypes(),
workflowsStore.fetchWorkflow(workflowId.value),
]);
await loadingPromise;
// remove editor pinned data
workflow.value.pinData = {};
initializeWorkspace(workflow.value);
disableAllNodes();
}
function getNodeNameById(id: string) {
return mappedNodes.value.find((node) => node.id === id)?.data?.name;
}
function updateNodeClasses(nodeIds: string[], isPinned: boolean) {
eventBus.emit('nodes:action', {
ids: nodeIds,
action: 'update:node:class',
payload: {
className: style.pinnedNode,
add: isPinned,
},
});
eventBus.emit('nodes:action', {
ids: nodeIds,
action: 'update:node:class',
payload: {
className: style.notPinnedNode,
add: !isPinned,
},
});
}
function disableAllNodes() {
const ids = mappedNodes.value.map((node) => node.id);
updateNodeClasses(ids, false);
const pinnedNodes = props.modelValue.map((node) => node.id).filter((id) => id !== null);
if (pinnedNodes.length > 0) {
updateNodeClasses(pinnedNodes, true);
}
function isMocked(data: CanvasNodeData) {
return model.value.some((node) => node.id === data.id);
}
function onPinButtonClick(data: CanvasNodeData) {
function canBeMocked(outputs: CanvasConnectionPort[], inputs: CanvasConnectionPort[]) {
return outputs.length === 1 && inputs.length >= 1;
}
function handleNodeClick(data: CanvasNodeData) {
const nodeName = getNodeNameById(data.id);
if (!nodeName) return;
if (!nodeName || !canBeMocked(data.outputs, data.inputs)) return;
const isPinned = props.modelValue.some((node) => node.id === data.id);
const updatedNodes = isPinned
? props.modelValue.filter((node) => node.id !== data.id)
: [...props.modelValue, { name: nodeName, id: data.id }];
const mocked = isMocked(data);
emit('update:modelValue', updatedNodes);
updateNodeClasses([data.id], !isPinned);
model.value = mocked
? model.value.filter((node) => node.id !== data.id)
: model.value.concat({ name: nodeName, id: data.id });
if (!isPinned) {
if (!mocked) {
telemetry.track('User selected node to be mocked', {
node_id: data.id,
test_id: testId.value,
});
}
}
function isPinButtonVisible(outputs: CanvasConnectionPort[], inputs: CanvasConnectionPort[]) {
return outputs.length === 1 && inputs.length >= 1;
function tooltipContent(data: CanvasNodeData) {
if (nodeTypesStore.isTriggerNode(data.type)) {
return locale.baseText('testDefinition.edit.nodesPinning.triggerTooltip');
}
if (!canBeMocked(data.outputs, data.inputs)) {
return;
}
if (isMocked(data)) {
return locale.baseText('testDefinition.edit.nodesPinning.pinButtonTooltip.pinned');
} else {
return locale.baseText('testDefinition.edit.nodesPinning.pinButtonTooltip');
}
}
const isPinned = (data: CanvasNodeData) => props.modelValue.some((node) => node.id === data.id);
function tooltipOffset(data: CanvasNodeData) {
if (nodeTypesStore.isTriggerNode(data.type)) return;
return 45 * viewport.value.zoom;
}
function tooltipProps(data: CanvasNodeData) {
const content = tooltipContent(data);
return {
disabled: !content,
content,
offset: tooltipOffset(data),
};
}
onNodeClick(({ node }) => handleNodeClick(node.data));
onNodesInitialized(async () => {
await fitView();
await zoomTo(0.7);
// Wait for the zoom to be applied and the canvas edges to recompute
await new Promise((resolve) => setTimeout(resolve, 400));
isLoading.value = false;
await zoomTo(0.7, { duration: 400 });
});
onMounted(loadData);
</script>
<template>
<div v-if="mappedNodes.length === 0" :class="$style.noNodes">
<N8nHeading size="large" :bold="true" :class="$style.noNodesTitle">{{
locale.baseText('testDefinition.edit.pinNodes.noNodes.title')
}}</N8nHeading>
<N8nHeading size="large" :bold="true" :class="$style.noNodesTitle">
{{ locale.baseText('testDefinition.edit.pinNodes.noNodes.title') }}
</N8nHeading>
<N8nText>{{ locale.baseText('testDefinition.edit.pinNodes.noNodes.description') }}</N8nText>
</div>
<div v-else :class="$style.container">
<N8nSpinner v-if="isLoading" size="xlarge" type="dots" :class="$style.spinner" />
<N8nSpinner v-if="isLoading" size="large" type="dots" :class="$style.spinner" />
<Canvas
:id="canvasId"
:loading="isLoading"
:class="{ [$style.canvas]: true }"
:nodes="mappedNodes"
:connections="mappedConnections"
:show-bug-reporting-button="false"
:read-only="true"
:event-bus="eventBus"
>
<template #nodeToolbar="{ data, outputs, inputs }">
<div
v-if="isPinButtonVisible(outputs, inputs)"
:class="{
[$style.pinButtonContainer]: true,
[$style.pinButtonContainerPinned]: isPinned(data),
}"
>
<N8nTooltip placement="left">
<template #content>
{{ locale.baseText('testDefinition.edit.nodesPinning.pinButtonTooltip') }}
<template #node="{ nodeProps }">
<N8nTooltip placement="top" v-bind="tooltipProps(nodeProps.data)">
<CanvasNode
v-bind="nodeProps"
:class="{
[$style.isTrigger]: nodeTypesStore.isTriggerNode(nodeProps.data.type),
[$style.mockNode]: true,
}"
>
<template #toolbar="{ data, outputs, inputs }">
<div
v-if="canBeMocked(outputs, inputs)"
:class="{
[$style.pinButtonContainer]: true,
[$style.pinButtonContainerPinned]: isMocked(data),
}"
>
<N8nButton
icon="thumbtack"
block
type="secondary"
:class="{ [$style.customSecondary]: isMocked(data) }"
data-test-id="node-pin-button"
>
<template v-if="isMocked(data)">
{{ locale.baseText('contextMenu.unpin') }}
</template>
<template v-else> {{ locale.baseText('contextMenu.pin') }}</template>
</N8nButton>
</div>
</template>
<N8nButton
v-if="isPinned(data)"
icon="thumbtack"
block
type="secondary"
:class="$style.customSecondary"
data-test-id="node-pin-button"
@click="onPinButtonClick(data)"
>
Un Mock
</N8nButton>
<N8nButton
v-else
icon="thumbtack"
block
type="secondary"
data-test-id="node-pin-button"
@click="onPinButtonClick(data)"
>
Mock
</N8nButton>
</N8nTooltip>
</div>
</CanvasNode>
</N8nTooltip>
</template>
</Canvas>
</div>
</template>
<style lang="scss" module>
.mockNode {
// remove selection outline
--color-canvas-selected-transparent: transparent;
}
.isTrigger {
--canvas-node--border-color: var(--color-secondary);
}
.container {
width: 100vw;
width: 100%;
height: 100%;
border: 1px solid var(--color-foreground-light);
border-radius: 8px;
}
.pinButtonContainer {
position: absolute;
@@ -202,16 +217,16 @@ onMounted(loadData);
transform: translateX(50%);
&.pinButtonContainerPinned {
background-color: hsla(247, 49%, 55%, 1);
background-color: var(--color-secondary);
}
}
.customSecondary {
--button-background-color: hsla(247, 49%, 55%, 1);
--button-background-color: var(--color-secondary);
--button-font-color: var(--color-button-primary-font);
--button-border-color: hsla(247, 49%, 55%, 1);
--button-border-color: var(--color-secondary);
--button-hover-background-color: hsla(247, 49%, 55%, 1);
--button-hover-background-color: var(--color-secondary);
--button-hover-border-color: var(--color-button-primary-font);
--button-hover-font-color: var(--color-button-primary-font);
}

View File

@@ -3,15 +3,16 @@ import type { TestMetricRecord } from '@/api/testDefinition.ee';
import BlockArrow from '@/components/TestDefinition/EditDefinition/BlockArrow.vue';
import EvaluationStep from '@/components/TestDefinition/EditDefinition/EvaluationStep.vue';
import MetricsInput from '@/components/TestDefinition/EditDefinition/MetricsInput.vue';
import NodesPinning from '@/components/TestDefinition/EditDefinition/NodesPinning.vue';
import WorkflowSelector from '@/components/TestDefinition/EditDefinition/WorkflowSelector.vue';
import type { EditableFormState, EvaluationFormState } from '@/components/TestDefinition/types';
import { useI18n } from '@/composables/useI18n';
import { useMessage } from '@/composables/useMessage';
import { NODE_PINNING_MODAL_KEY } from '@/constants';
import type { ITag, ModalState } from '@/Interface';
import { N8nButton, N8nTag, N8nText } from '@n8n/design-system';
import type { ITag } from '@/Interface';
import { N8nButton, N8nHeading, N8nTag, N8nText } from '@n8n/design-system';
import type { IPinData } from 'n8n-workflow';
import { computed, ref } from 'vue';
import { computed } from 'vue';
const props = defineProps<{
tagsById: Record<string, ITag>;
@@ -68,8 +69,6 @@ const mockedNodes = defineModel<EvaluationFormState['mockedNodes']>('mockedNodes
required: true,
});
const nodePinningModal = ref<ModalState | null>(null);
const selectedTag = computed(() => props.tagsById[tags.value.value[0]] ?? {});
function openExecutionsView() {
@@ -198,13 +197,17 @@ function openExecutionsView() {
</template>
</EvaluationStep>
</div>
<Modal ref="nodePinningModal" width="80vw" height="85vh" :name="NODE_PINNING_MODAL_KEY">
<Modal
width="calc(100% - (48px * 2))"
height="calc(100% - (48px * 2))"
:custom-class="$style.pinnigModal"
:name="NODE_PINNING_MODAL_KEY"
>
<template #header>
<N8nHeading size="large" :bold="true">
<N8nHeading tag="h3" size="xlarge" color="text-dark" class="mb-2xs">
{{ locale.baseText('testDefinition.edit.selectNodes') }}
</N8nHeading>
<br />
<N8nText>
<N8nText color="text-base">
{{ locale.baseText('testDefinition.edit.modal.description') }}
</N8nText>
</template>
@@ -216,6 +219,11 @@ function openExecutionsView() {
</template>
<style module lang="scss">
.pinnigModal {
--dialog-max-width: none;
margin: 0;
}
.nestedSteps {
display: grid;
grid-template-columns: 20% 1fr;

View File

@@ -94,21 +94,18 @@ describe('NodesPinning', () => {
expect(container.querySelector('[data-node-name="Node 2"]')).toBeInTheDocument();
});
it('should update node classes when pinning/unpinning nodes', async () => {
const { container } = renderComponent();
it('should update UI when pinning/unpinning nodes', async () => {
const { container, getAllByTestId } = renderComponent();
await waitFor(() => {
expect(container.querySelector('[data-node-name="Node 1"]')).toBeInTheDocument();
});
await waitFor(() => {
expect(container.querySelector('[data-node-name="Node 1"]')).toHaveClass(
'canvasNode pinnedNode',
);
expect(container.querySelector('[data-node-name="Node 2"]')).toHaveClass(
'canvasNode notPinnedNode',
);
});
const buttons = getAllByTestId('node-pin-button');
expect(buttons.length).toBe(2);
expect(buttons[0]).toHaveTextContent('Unpin');
expect(buttons[1]).toHaveTextContent('Pin');
});
it('should emit update:modelValue when pinning nodes', async () => {

View File

@@ -1,23 +1,42 @@
<script lang="ts" setup>
import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
import {
type CanvasLayoutEvent,
type CanvasLayoutSource,
useCanvasLayout,
} from '@/composables/useCanvasLayout';
import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover';
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
import { type ContextMenuAction, useContextMenu } from '@/composables/useContextMenu';
import { useKeybindings } from '@/composables/useKeybindings';
import type { PinDataSource } from '@/composables/usePinnedData';
import { CanvasKey } from '@/constants';
import type { NodeCreatorOpenSource } from '@/Interface';
import {
type CanvasConnection,
type CanvasEventBusEvents,
type CanvasNode,
type CanvasNodeMoveEvent,
type CanvasEventBusEvents,
type ConnectStartEvent,
CanvasNodeRenderType,
} from '@/types';
import { GRID_SIZE } from '@/utils/nodeViewUtils';
import { isPresent } from '@/utils/typesUtils';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useShortKeyPress } from '@n8n/composables/useShortKeyPress';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import type {
Connection,
XYPosition,
GraphNode,
NodeDragEvent,
NodeMouseEvent,
GraphNode,
XYPosition,
} from '@vue-flow/core';
import { useVueFlow, VueFlow, PanelPosition, MarkerType } from '@vue-flow/core';
import { MarkerType, PanelPosition, useVueFlow, VueFlow } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap';
import Node from './elements/nodes/CanvasNode.vue';
import Edge from './elements/edges/CanvasEdge.vue';
import { onKeyDown, onKeyUp, useThrottleFn } from '@vueuse/core';
import { NodeConnectionType } from 'n8n-workflow';
import {
computed,
nextTick,
@@ -29,29 +48,10 @@ import {
useCssModule,
watch,
} from 'vue';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useShortKeyPress } from '@n8n/composables/useShortKeyPress';
import { useContextMenu, type ContextMenuAction } from '@/composables/useContextMenu';
import { useKeybindings } from '@/composables/useKeybindings';
import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
import type { NodeCreatorOpenSource } from '@/Interface';
import type { PinDataSource } from '@/composables/usePinnedData';
import { isPresent } from '@/utils/typesUtils';
import { GRID_SIZE } from '@/utils/nodeViewUtils';
import { CanvasKey } from '@/constants';
import { onKeyDown, onKeyUp, useThrottleFn } from '@vueuse/core';
import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue';
import CanvasBackground from './elements/background/CanvasBackground.vue';
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
import { NodeConnectionType } from 'n8n-workflow';
import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover';
import {
type CanvasLayoutEvent,
type CanvasLayoutSource,
useCanvasLayout,
} from '@/composables/useCanvasLayout';
import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue';
import Edge from './elements/edges/CanvasEdge.vue';
import Node from './elements/nodes/CanvasNode.vue';
const $style = useCssModule();
@@ -804,29 +804,31 @@ provide(CanvasKey, {
@drop="onDrop"
>
<template #node-canvas-node="nodeProps">
<Node
v-bind="nodeProps"
:read-only="readOnly"
:event-bus="eventBus"
:hovered="nodesHoveredById[nodeProps.id]"
:nearby-hovered="nodeProps.id === hoveredTriggerNode.id.value"
@delete="onDeleteNode"
@run="onRunNode"
@select="onSelectNode"
@toggle="onToggleNodeEnabled"
@activate="onSetNodeActivated"
@deactivate="onSetNodeDeactivated"
@open:contextmenu="onOpenNodeContextMenu"
@update="onUpdateNodeParameters"
@update:inputs="onUpdateNodeInputs"
@update:outputs="onUpdateNodeOutputs"
@move="onUpdateNodePosition"
@add="onClickNodeAdd"
>
<template v-if="$slots.nodeToolbar" #toolbar="toolbarProps">
<slot name="nodeToolbar" v-bind="toolbarProps" />
</template>
</Node>
<slot name="node" v-bind="{ nodeProps }">
<Node
v-bind="nodeProps"
:read-only="readOnly"
:event-bus="eventBus"
:hovered="nodesHoveredById[nodeProps.id]"
:nearby-hovered="nodeProps.id === hoveredTriggerNode.id.value"
@delete="onDeleteNode"
@run="onRunNode"
@select="onSelectNode"
@toggle="onToggleNodeEnabled"
@activate="onSetNodeActivated"
@deactivate="onSetNodeDeactivated"
@open:contextmenu="onOpenNodeContextMenu"
@update="onUpdateNodeParameters"
@update:inputs="onUpdateNodeInputs"
@update:outputs="onUpdateNodeOutputs"
@move="onUpdateNodePosition"
@add="onClickNodeAdd"
>
<template v-if="$slots.nodeToolbar" #toolbar="toolbarProps">
<slot name="nodeToolbar" v-bind="toolbarProps" />
</template>
</Node>
</slot>
</template>
<template #edge-canvas-edge="edgeProps">