mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
195 lines
5.2 KiB
Vue
195 lines
5.2 KiB
Vue
|
<script setup lang="ts">
|
||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||
|
import { computed, onMounted, ref, useCssModule } from 'vue';
|
||
|
import { useRoute, useRouter } from 'vue-router';
|
||
|
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||
|
import { useCanvasMapping } from '@/composables/useCanvasMapping';
|
||
|
import { createEventBus, N8nTooltip } from 'n8n-design-system';
|
||
|
import type { CanvasConnectionPort, CanvasEventBusEvents, CanvasNodeData } from '@/types';
|
||
|
import { useVueFlow } from '@vue-flow/core';
|
||
|
import { useI18n } from '@/composables/useI18n';
|
||
|
|
||
|
const workflowsStore = useWorkflowsStore();
|
||
|
const nodeTypesStore = useNodeTypesStore();
|
||
|
const route = useRoute();
|
||
|
const router = useRouter();
|
||
|
const locale = useI18n();
|
||
|
|
||
|
const { resetWorkspace, initializeWorkspace } = useCanvasOperations({ router });
|
||
|
|
||
|
const eventBus = createEventBus<CanvasEventBusEvents>();
|
||
|
const style = useCssModule();
|
||
|
const uuid = crypto.randomUUID();
|
||
|
const props = defineProps<{
|
||
|
modelValue: Array<{ name: string }>;
|
||
|
}>();
|
||
|
|
||
|
const emit = defineEmits<{
|
||
|
'update:modelValue': [value: Array<{ name: string }>];
|
||
|
}>();
|
||
|
|
||
|
const isLoading = ref(true);
|
||
|
|
||
|
const workflowId = computed(() => route.params.name as string);
|
||
|
const testId = computed(() => route.params.testId as string);
|
||
|
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 connections = computed(() => workflow.value.connections);
|
||
|
|
||
|
const { nodes: mappedNodes, connections: mappedConnections } = useCanvasMapping({
|
||
|
nodes,
|
||
|
connections,
|
||
|
workflowObject,
|
||
|
});
|
||
|
async function loadData() {
|
||
|
workflowsStore.resetState();
|
||
|
resetWorkspace();
|
||
|
const loadingPromise = Promise.all([
|
||
|
nodeTypesStore.getNodeTypes(),
|
||
|
workflowsStore.fetchWorkflow(workflowId.value),
|
||
|
]);
|
||
|
await loadingPromise;
|
||
|
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) => {
|
||
|
const matchedNode = mappedNodes.value.find(
|
||
|
(mappedNode) => mappedNode?.data?.name === node.name,
|
||
|
);
|
||
|
return matchedNode?.id ?? null;
|
||
|
})
|
||
|
.filter((n) => n !== null);
|
||
|
|
||
|
if (pinnedNodes.length > 0) {
|
||
|
updateNodeClasses(pinnedNodes, true);
|
||
|
}
|
||
|
}
|
||
|
function onPinButtonClick(data: CanvasNodeData) {
|
||
|
const nodeName = getNodeNameById(data.id);
|
||
|
if (!nodeName) return;
|
||
|
|
||
|
const isPinned = props.modelValue.some((node) => node.name === nodeName);
|
||
|
const updatedNodes = isPinned
|
||
|
? props.modelValue.filter((node) => node.name !== nodeName)
|
||
|
: [...props.modelValue, { name: nodeName }];
|
||
|
|
||
|
emit('update:modelValue', updatedNodes);
|
||
|
updateNodeClasses([data.id], !isPinned);
|
||
|
}
|
||
|
function isPinButtonVisible(outputs: CanvasConnectionPort[]) {
|
||
|
return outputs.length === 1;
|
||
|
}
|
||
|
|
||
|
onNodesInitialized(async () => {
|
||
|
await fitView();
|
||
|
isLoading.value = false;
|
||
|
await zoomTo(0.7, { duration: 400 });
|
||
|
});
|
||
|
onMounted(loadData);
|
||
|
</script>
|
||
|
|
||
|
<template>
|
||
|
<div :class="$style.container">
|
||
|
<N8nSpinner v-if="isLoading" size="xlarge" 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 }">
|
||
|
<div :class="$style.pinButtonContainer">
|
||
|
<N8nTooltip v-if="isPinButtonVisible(outputs)" placement="left">
|
||
|
<template #content>
|
||
|
{{ locale.baseText('testDefinition.edit.nodesPinning.pinButtonTooltip') }}
|
||
|
</template>
|
||
|
<n8n-icon-button
|
||
|
type="tertiary"
|
||
|
size="large"
|
||
|
icon="thumbtack"
|
||
|
:class="$style.pinButton"
|
||
|
@click="onPinButtonClick(data)"
|
||
|
/>
|
||
|
</N8nTooltip>
|
||
|
</div>
|
||
|
</template>
|
||
|
</Canvas>
|
||
|
</div>
|
||
|
</template>
|
||
|
|
||
|
<style lang="scss" module>
|
||
|
.container {
|
||
|
width: 100vw;
|
||
|
height: 100%;
|
||
|
}
|
||
|
.pinButtonContainer {
|
||
|
position: absolute;
|
||
|
right: 0;
|
||
|
display: flex;
|
||
|
justify-content: flex-end;
|
||
|
bottom: 100%;
|
||
|
}
|
||
|
|
||
|
.pinButton {
|
||
|
cursor: pointer;
|
||
|
color: var(--canvas-node--border-color);
|
||
|
border: none;
|
||
|
}
|
||
|
.notPinnedNode,
|
||
|
.pinnedNode {
|
||
|
:global(.n8n-node-icon) > div {
|
||
|
filter: contrast(40%) brightness(1.5) grayscale(100%);
|
||
|
}
|
||
|
}
|
||
|
.pinnedNode {
|
||
|
--canvas-node--border-color: hsla(247, 49%, 55%, 1);
|
||
|
|
||
|
:global(.n8n-node-icon) > div {
|
||
|
filter: contrast(40%) brightness(1.5) grayscale(100%);
|
||
|
}
|
||
|
}
|
||
|
.spinner {
|
||
|
position: absolute;
|
||
|
top: 50%;
|
||
|
left: 50%;
|
||
|
transform: translate(-50%, -50%);
|
||
|
}
|
||
|
</style>
|