1
1
mirror of https://github.com/n8n-io/n8n.git synced 2024-08-16 16:40:30 +03:00

feat(editor): Add execute workflow functionality and statuses to new canvas (no-changelog) (#9902)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
Alex Grozav 2024-07-08 13:25:18 +03:00 committed by GitHub
parent 1807835740
commit 8f970b5d37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1394 additions and 330 deletions

View File

@ -43,6 +43,7 @@
"@jsplumb/util": "^5.13.2",
"@lezer/common": "^1.0.4",
"@n8n/chat": "workspace:*",
"@n8n/codemirror-lang": "workspace:*",
"@n8n/codemirror-lang-sql": "^1.0.2",
"@n8n/permissions": "workspace:*",
"@vue-flow/background": "^1.3.0",
@ -55,7 +56,6 @@
"axios": "1.6.7",
"chart.js": "^4.4.0",
"codemirror-lang-html-n8n": "^1.0.0",
"@n8n/codemirror-lang": "workspace:*",
"dateformat": "^3.0.3",
"email-providers": "^2.0.1",
"esprima-next": "5.8.4",

View File

@ -10,9 +10,17 @@ export function createCanvasNodeData({
inputs = [],
outputs = [],
connections = { input: {}, output: {} },
execution = {},
issues = { items: [], visible: false },
pinnedData = { count: 0, visible: false },
runData = { count: 0, visible: false },
renderType = 'default',
}: Partial<CanvasElementData> = {}): CanvasElementData {
return {
execution,
issues,
pinnedData,
runData,
id,
type,
typeVersion,

View File

@ -9,6 +9,7 @@ import type {
IWorkflowSettings,
LoadedClass,
INodeTypeDescription,
INodeIssues,
} from 'n8n-workflow';
import { NodeHelpers, Workflow } from 'n8n-workflow';
import { uuid } from '@jsplumb/util';
@ -23,6 +24,7 @@ import {
NO_OP_NODE_TYPE,
SET_NODE_TYPE,
} from '@/constants';
import type { INodeUi } from '@/Interface';
export const mockNode = ({
id = uuid(),
@ -30,22 +32,30 @@ export const mockNode = ({
type,
position = [0, 0],
disabled = false,
issues = undefined,
typeVersion = 1,
}: {
id?: INode['id'];
name: INode['name'];
type: INode['type'];
position?: INode['position'];
disabled?: INode['disabled'];
}) => mock<INode>({ id, name, type, position, disabled });
id?: INodeUi['id'];
name: INodeUi['name'];
type: INodeUi['type'];
position?: INodeUi['position'];
disabled?: INodeUi['disabled'];
issues?: INodeIssues;
typeVersion?: INodeUi['typeVersion'];
}) => mock<INodeUi>({ id, name, type, position, disabled, issues, typeVersion });
export const mockNodeTypeDescription = ({
name,
version = 1,
credentials = [],
inputs = ['main'],
outputs = ['main'],
}: {
name: INodeTypeDescription['name'];
version?: INodeTypeDescription['version'];
credentials?: INodeTypeDescription['credentials'];
inputs?: INodeTypeDescription['inputs'];
outputs?: INodeTypeDescription['outputs'];
}) =>
mock<INodeTypeDescription>({
name,
@ -58,8 +68,8 @@ export const mockNodeTypeDescription = ({
properties: [],
maxNodes: Infinity,
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
inputs: ['main'],
outputs: ['main'],
inputs,
outputs,
credentials,
documentationUrl: 'https://docs',
webhooks: undefined,

View File

@ -58,6 +58,10 @@ function onNodeDragStop(e: NodeDragEvent) {
});
}
function onSelectionDragStop(e: NodeDragEvent) {
onNodeDragStop(e);
}
function onSetNodeActive(id: string) {
emit('update:node:active', id);
}
@ -121,6 +125,7 @@ function onClickPane(event: MouseEvent) {
:max-zoom="2"
data-test-id="canvas"
@node-drag-stop="onNodeDragStop"
@selection-drag-stop="onSelectionDragStop"
@edge-mouse-enter="onMouseEnterEdge"
@edge-mouse-leave="onMouseLeaveEdge"
@pane-click="onClickPane"
@ -156,8 +161,6 @@ function onClickPane(event: MouseEvent) {
</VueFlow>
</template>
<style lang="scss" module></style>
<style lang="scss">
.vue-flow__controls {
display: flex;

View File

@ -1,6 +1,8 @@
import { fireEvent } from '@testing-library/vue';
import CanvasEdge from './CanvasEdge.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
const renderComponent = createComponentRenderer(CanvasEdge, {
props: {
@ -10,9 +12,17 @@ const renderComponent = createComponentRenderer(CanvasEdge, {
targetX: 100,
targetY: 100,
targetPosition: 'bottom',
data: {
status: undefined,
},
},
});
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
});
describe('CanvasEdge', () => {
it('should emit delete event when toolbar delete is clicked', async () => {
const { emitted, getByTestId } = renderComponent();
@ -24,19 +34,12 @@ describe('CanvasEdge', () => {
});
it('should compute edgeStyle correctly', () => {
const { container } = renderComponent({
props: {
style: {
stroke: 'red',
},
},
});
const { container } = renderComponent();
const edge = container.querySelector('.vue-flow__edge-path');
expect(edge).toHaveStyle({
stroke: 'red',
strokeWidth: 2,
stroke: 'var(--color-foreground-xdark)',
});
});
});

View File

@ -17,12 +17,40 @@ const props = defineProps<
const $style = useCssModule();
const isFocused = computed(() => props.selected || props.hovered);
const status = computed(() => props.data.status);
const statusColor = computed(() => {
if (props.selected) {
return 'var(--color-background-dark)';
} else if (status.value === 'success') {
return 'var(--color-success)';
} else if (status.value === 'pinned') {
return 'var(--color-secondary)';
} else {
return 'var(--color-foreground-xdark)';
}
});
const edgeStyle = computed(() => ({
strokeWidth: 2,
...props.style,
strokeWidth: 2,
stroke: statusColor.value,
}));
const isEdgeToolbarVisible = computed(() => props.selected || props.hovered);
const edgeLabel = computed(() => {
if (isFocused.value) {
return '';
}
return props.label;
});
const edgeLabelStyle = computed(() => ({
fill: statusColor.value,
transform: 'translateY(calc(var(--spacing-xs) * -1))',
fontSize: 'var(--font-size-xs)',
}));
const edgeToolbarStyle = computed(() => {
return {
@ -32,7 +60,7 @@ const edgeToolbarStyle = computed(() => {
const edgeToolbarClasses = computed(() => ({
[$style.edgeToolbar]: true,
[$style.edgeToolbarVisible]: isEdgeToolbarVisible.value,
[$style.edgeToolbarVisible]: isFocused.value,
nodrag: true,
nopan: true,
}));
@ -63,18 +91,15 @@ function onDelete() {
<template>
<BaseEdge
:id="id"
:class="$style.edge"
:style="edgeStyle"
:path="path[0]"
:marker-end="markerEnd"
:label="data?.label"
:label="edgeLabel"
:label-x="path[1]"
:label-y="path[2]"
:label-style="{ fill: 'white' }"
:label-show-bg="true"
:label-bg-style="{ fill: 'red' }"
:label-bg-padding="[2, 4]"
:label-bg-border-radius="2"
:class="$style.edge"
:label-style="edgeLabelStyle"
:label-show-bg="false"
/>
<EdgeLabelRenderer>
<CanvasEdgeToolbar :class="edgeToolbarClasses" :style="edgeToolbarStyle" @delete="onDelete" />
@ -82,6 +107,10 @@ function onDelete() {
</template>
<style lang="scss" module>
.edge {
transition: stroke 0.3s ease;
}
.edgeToolbar {
position: absolute;
opacity: 0;

View File

@ -1,9 +1,16 @@
import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
const renderComponent = createComponentRenderer(CanvasNodeRenderer);
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
});
describe('CanvasNodeRenderer', () => {
it('should render default node correctly', async () => {
const { getByTestId } = renderComponent({

View File

@ -2,9 +2,16 @@ import CanvasNodeConfigurable from '@/components/canvas/elements/nodes/render-ty
import { createComponentRenderer } from '@/__tests__/render';
import { NodeConnectionType } from 'n8n-workflow';
import { createCanvasNodeProvide } from '@/__tests__/data';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
const renderComponent = createComponentRenderer(CanvasNodeConfigurable);
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
});
describe('CanvasNodeConfigurable', () => {
it('should render node correctly', () => {
const { getByText } = renderComponent({

View File

@ -1,33 +1,40 @@
<script lang="ts" setup>
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey, NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
import { computed, useCssModule } from 'vue';
import { NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { useI18n } from '@/composables/useI18n';
import CanvasNodeDisabledStrikeThrough from './parts/CanvasNodeDisabledStrikeThrough.vue';
const node = inject(CanvasNodeKey);
import CanvasNodeStatusIcons from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue';
import { useCanvasNode } from '@/composables/useCanvasNode';
const $style = useCssModule();
const i18n = useI18n();
const label = computed(() => node?.label.value ?? '');
const inputs = computed(() => node?.data.value.inputs ?? []);
const outputs = computed(() => node?.data.value.outputs ?? []);
const connections = computed(() => node?.data.value.connections ?? { input: {}, output: {} });
const {
label,
inputs,
outputs,
connections,
isDisabled,
isSelected,
hasPinnedData,
hasRunData,
hasIssues,
} = useCanvasNode();
const { nonMainInputs, requiredNonMainInputs } = useNodeConnections({
inputs,
outputs,
connections,
});
const isDisabled = computed(() => node?.data.value.disabled ?? false);
const classes = computed(() => {
return {
[$style.node]: true,
[$style.selected]: node?.selected.value,
[$style.selected]: isSelected.value,
[$style.disabled]: isDisabled.value,
[$style.success]: hasRunData.value,
[$style.error]: hasIssues.value,
[$style.pinned]: hasPinnedData.value,
};
});
@ -54,6 +61,7 @@ const styles = computed(() => {
<template>
<div :class="classes" :style="styles" data-test-id="canvas-node-configurable">
<slot />
<CanvasNodeStatusIcons :class="$style.statusIcons" />
<CanvasNodeDisabledStrikeThrough v-if="isDisabled" />
<div :class="$style.label">
{{ label }}
@ -80,6 +88,31 @@ const styles = computed(() => {
background: var(--canvas-node--background, var(--color-node-background));
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-xdark));
border-radius: var(--border-radius-large);
&.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
/**
* State classes
* The reverse order defines the priority in case multiple states are active
*/
&.error {
border-color: var(--color-canvas-node-error-border-color, var(--color-danger));
}
&.success {
border-color: var(--color-canvas-node-success-border-color, var(--color-success));
}
&.pinned {
border-color: var(--color-canvas-node-pinned-border, var(--color-node-pinned-border));
}
&.disabled {
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
}
}
.label {
@ -93,11 +126,9 @@ const styles = computed(() => {
);
}
.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
.disabled {
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
.statusIcons {
position: absolute;
top: calc(var(--canvas-node--height) - 24px);
right: var(--spacing-xs);
}
</style>

View File

@ -1,9 +1,16 @@
import CanvasNodeConfiguration from '@/components/canvas/elements/nodes/render-types/CanvasNodeConfiguration.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
const renderComponent = createComponentRenderer(CanvasNodeConfiguration);
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
});
describe('CanvasNodeConfiguration', () => {
it('should render node correctly', () => {
const { getByText } = renderComponent({

View File

@ -1,22 +1,20 @@
<script lang="ts" setup>
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey } from '@/constants';
import { computed, useCssModule } from 'vue';
import { useI18n } from '@/composables/useI18n';
const node = inject(CanvasNodeKey);
import CanvasNodeStatusIcons from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue';
import { useCanvasNode } from '@/composables/useCanvasNode';
const $style = useCssModule();
const i18n = useI18n();
const label = computed(() => node?.label.value ?? '');
const isDisabled = computed(() => node?.data.value.disabled ?? false);
const { label, isDisabled, isSelected, hasIssues } = useCanvasNode();
const classes = computed(() => {
return {
[$style.node]: true,
[$style.selected]: node?.selected.value,
[$style.selected]: isSelected.value,
[$style.disabled]: isDisabled.value,
[$style.error]: hasIssues.value,
};
});
</script>
@ -24,6 +22,7 @@ const classes = computed(() => {
<template>
<div :class="classes" data-test-id="canvas-node-configuration">
<slot />
<CanvasNodeStatusIcons :class="$style.statusIcons" />
<div v-if="label" :class="$style.label">
{{ label }}
<div v-if="isDisabled">({{ i18n.baseText('node.disabled') }})</div>
@ -44,6 +43,23 @@ const classes = computed(() => {
background: var(--canvas-node--background, var(--node-type-supplemental-background));
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-dark));
border-radius: 50%;
/**
* State classes
* The reverse order defines the priority in case multiple states are active
*/
&.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
&.error {
border-color: var(--color-canvas-node-error-border-color, var(--color-danger));
}
&.disabled {
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
}
}
.label {
@ -56,11 +72,8 @@ const classes = computed(() => {
margin-top: var(--spacing-2xs);
}
.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
.disabled {
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
.statusIcons {
position: absolute;
top: calc(var(--canvas-node--height) - 24px);
}
</style>

View File

@ -2,9 +2,16 @@ import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/C
import { createComponentRenderer } from '@/__tests__/render';
import { NodeConnectionType } from 'n8n-workflow';
import { createCanvasNodeProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
const renderComponent = createComponentRenderer(CanvasNodeDefault);
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
});
describe('CanvasNodeDefault', () => {
it('should render node correctly', () => {
const { getByText } = renderComponent({

View File

@ -1,33 +1,39 @@
<script lang="ts" setup>
import { computed, inject, useCssModule } from 'vue';
import { computed, useCssModule } from 'vue';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { CanvasNodeKey } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import CanvasNodeDisabledStrikeThrough from './parts/CanvasNodeDisabledStrikeThrough.vue';
const node = inject(CanvasNodeKey);
import CanvasNodeStatusIcons from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue';
import { useCanvasNode } from '@/composables/useCanvasNode';
const $style = useCssModule();
const i18n = useI18n();
const label = computed(() => node?.label.value ?? '');
const inputs = computed(() => node?.data.value.inputs ?? []);
const outputs = computed(() => node?.data.value.outputs ?? []);
const connections = computed(() => node?.data.value.connections ?? { input: {}, output: {} });
const {
label,
inputs,
outputs,
connections,
isDisabled,
isSelected,
hasPinnedData,
hasRunData,
hasIssues,
} = useCanvasNode();
const { mainOutputs } = useNodeConnections({
inputs,
outputs,
connections,
});
const isDisabled = computed(() => node?.data.value.disabled ?? false);
const classes = computed(() => {
return {
[$style.node]: true,
[$style.selected]: node?.selected.value,
[$style.selected]: isSelected.value,
[$style.disabled]: isDisabled.value,
[$style.success]: hasRunData.value,
[$style.error]: hasIssues.value,
[$style.pinned]: hasPinnedData.value,
};
});
@ -39,8 +45,9 @@ const styles = computed(() => {
</script>
<template>
<div v-if="node" :class="classes" :style="styles" data-test-id="canvas-node-default">
<div :class="classes" :style="styles" data-test-id="canvas-node-default">
<slot />
<CanvasNodeStatusIcons :class="$style.statusIcons" />
<CanvasNodeDisabledStrikeThrough v-if="isDisabled" />
<div v-if="label" :class="$style.label">
{{ label }}
@ -62,6 +69,31 @@ const styles = computed(() => {
background: var(--canvas-node--background, var(--color-node-background));
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-xdark));
border-radius: var(--border-radius-large);
/**
* State classes
* The reverse order defines the priority in case multiple states are active
*/
&.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
&.success {
border-color: var(--color-canvas-node-success-border-color, var(--color-success));
}
&.error {
border-color: var(--color-canvas-node-error-border-color, var(--color-danger));
}
&.pinned {
border-color: var(--color-canvas-node-pinned-border-color, var(--color-node-pinned-border));
}
&.disabled {
border-color: var(--color-canvas-node-disabled-border-color, var(--color-foreground-base));
}
}
.label {
@ -74,11 +106,9 @@ const styles = computed(() => {
margin-top: var(--spacing-2xs);
}
.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
.disabled {
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
.statusIcons {
position: absolute;
top: calc(var(--canvas-node--height) - 24px);
right: var(--spacing-xs);
}
</style>

View File

@ -1,14 +1,11 @@
<script setup lang="ts">
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey } from '@/constants';
import { computed, useCssModule } from 'vue';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { useCanvasNode } from '@/composables/useCanvasNode';
const $style = useCssModule();
const node = inject(CanvasNodeKey);
const inputs = computed(() => node?.data.value.inputs ?? []);
const outputs = computed(() => node?.data.value.outputs ?? []);
const connections = computed(() => node?.data.value.connections ?? { input: {}, output: {} });
const { inputs, outputs, connections } = useCanvasNode();
const { mainInputConnections, mainOutputConnections } = useNodeConnections({
inputs,
outputs,

View File

@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from 'vue';
import TitledList from '@/components/TitledList.vue';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useCanvasNode } from '@/composables/useCanvasNode';
const nodeHelpers = useNodeHelpers();
const {
pinnedDataCount,
hasPinnedData,
issues,
hasIssues,
executionStatus,
executionWaiting,
hasRunData,
runDataCount,
} = useCanvasNode();
const hideNodeIssues = computed(() => false); // @TODO Implement this
</script>
<template>
<div :class="$style.canvasNodeStatusIcons">
<div v-if="hasIssues && !hideNodeIssues" :class="$style.issues" data-test-id="node-issues">
<N8nTooltip :show-after="500" placement="bottom">
<template #content>
<TitledList :title="`${$locale.baseText('node.issues')}:`" :items="issues" />
</template>
<FontAwesomeIcon icon="exclamation-triangle" />
</N8nTooltip>
</div>
<div v-else-if="executionWaiting || executionStatus === 'waiting'" class="waiting">
<N8nTooltip placement="bottom">
<template #content>
<div v-text="executionWaiting"></div>
</template>
<FontAwesomeIcon icon="clock" />
</N8nTooltip>
</div>
<span
v-else-if="hasPinnedData && !nodeHelpers.isProductionExecutionPreview.value"
:class="$style.pinnedData"
>
<FontAwesomeIcon icon="thumbtack" />
<span v-if="pinnedDataCount > 1" class="items-count"> {{ pinnedDataCount }}</span>
</span>
<span v-else-if="executionStatus === 'unknown'">
<!-- Do nothing, unknown means the node never executed -->
</span>
<span v-else-if="hasRunData" :class="$style.runData">
<FontAwesomeIcon icon="check" />
<span v-if="runDataCount > 1" :class="$style.itemsCount"> {{ runDataCount }}</span>
</span>
</div>
</template>
<style lang="scss" module>
.canvasNodeStatusIcons {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
}
.runData {
font-weight: 600;
color: var(--color-success);
}
.pinnedData {
color: var(--color-secondary);
}
.issues {
color: var(--color-danger);
cursor: default;
}
.itemsCount {
font-size: var(--font-size-s);
}
</style>

View File

@ -21,6 +21,7 @@ import { useRouter } from 'vue-router';
import { mock } from 'vitest-mock-extended';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
vi.mock('vue-router', async () => {
const actual = await import('vue-router');
@ -39,11 +40,12 @@ describe('useCanvasOperations', () => {
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
let credentialsStore: ReturnType<typeof useCredentialsStore>;
let canvasOperations: ReturnType<typeof useCanvasOperations>;
let workflowHelpers: ReturnType<typeof useWorkflowHelpers>;
const lastClickPosition = ref<XYPosition>([450, 450]);
const router = useRouter();
beforeEach(() => {
beforeEach(async () => {
const pinia = createPinia();
setActivePinia(pinia);
@ -53,15 +55,17 @@ describe('useCanvasOperations', () => {
historyStore = useHistoryStore();
nodeTypesStore = useNodeTypesStore();
credentialsStore = useCredentialsStore();
workflowHelpers = useWorkflowHelpers({ router });
const workflowId = 'test';
workflowsStore.workflowsById[workflowId] = mock<IWorkflowDb>({
const workflow = mock<IWorkflowDb>({
id: workflowId,
nodes: [],
tags: [],
usedCredentials: [],
});
workflowsStore.initializeEditableWorkflow(workflowId);
workflowsStore.workflowsById[workflowId] = workflow;
await workflowHelpers.initState(workflow, true);
canvasOperations = useCanvasOperations({ router, lastClickPosition });
});
@ -506,13 +510,13 @@ describe('useCanvasOperations', () => {
connection: [
{
index: 0,
node: 'Node B',
type: 'main',
node: 'Node A',
type: NodeConnectionType.Main,
},
{
index: 0,
node: 'spy',
type: 'main',
node: 'Node B',
type: NodeConnectionType.Main,
},
],
});
@ -567,6 +571,8 @@ describe('useCanvasOperations', () => {
name: 'Node B',
});
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB);
const connection: Connection = {
source: nodeA.id,
sourceHandle: `outputs/${NodeConnectionType.Main}/0`,
@ -574,7 +580,14 @@ describe('useCanvasOperations', () => {
targetHandle: `inputs/${NodeConnectionType.Main}/0`,
};
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB);
const nodeTypeDescription = mockNodeTypeDescription({
name: 'node',
inputs: [NodeConnectionType.Main],
});
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
canvasOperations.editableWorkflowObject.value.nodes[nodeA.name] = nodeA;
canvasOperations.editableWorkflowObject.value.nodes[nodeB.name] = nodeB;
canvasOperations.createConnection(connection);
@ -588,6 +601,189 @@ describe('useCanvasOperations', () => {
});
});
describe('isConnectionAllowed', () => {
it('should return false if source and target nodes are the same', () => {
const node = mockNode({ id: '1', type: 'testType', name: 'Test Node' });
expect(canvasOperations.isConnectionAllowed(node, node, NodeConnectionType.Main)).toBe(false);
});
it('should return false if target node type does not have inputs', () => {
const sourceNode = mockNode({
id: '1',
type: 'sourceType',
name: 'Source Node',
});
const targetNode = mockNode({
id: '2',
type: 'targetType',
name: 'Target Node',
});
const nodeTypeDescription = mockNodeTypeDescription({
name: 'targetType',
inputs: [],
});
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
expect(
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
).toBe(false);
});
it('should return false if target node does not exist in the workflow', () => {
const sourceNode = mockNode({
id: '1',
type: 'sourceType',
name: 'Source Node',
});
const targetNode = mockNode({
id: '2',
type: 'targetType',
name: 'Target Node',
});
const nodeTypeDescription = mockNodeTypeDescription({
name: 'targetType',
inputs: [NodeConnectionType.Main],
});
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
expect(
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
).toBe(false);
});
it('should return false if input type does not match connection type', () => {
const sourceNode = mockNode({
id: '1',
type: 'sourceType',
name: 'Source Node',
});
const targetNode = mockNode({
id: '2',
type: 'targetType',
name: 'Target Node',
});
const nodeTypeDescription = mockNodeTypeDescription({
name: 'targetType',
inputs: [NodeConnectionType.AiTool],
});
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode;
canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode;
expect(
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
).toBe(false);
});
it('should return false if source node type is not allowed by target node input filter', () => {
const sourceNode = mockNode({
id: '1',
type: 'sourceType',
name: 'Source Node',
typeVersion: 1,
});
const targetNode = mockNode({
id: '2',
type: 'targetType',
name: 'Target Node',
typeVersion: 1,
});
const nodeTypeDescription = mockNodeTypeDescription({
name: 'targetType',
inputs: [
{
type: NodeConnectionType.Main,
filter: {
nodes: ['allowedType'],
},
},
],
});
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode;
canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode;
expect(
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
).toBe(false);
});
it('should return true if all conditions including filter are met', () => {
const sourceNode = mockNode({
id: '1',
type: 'sourceType',
name: 'Source Node',
typeVersion: 1,
});
const targetNode = mockNode({
id: '2',
type: 'targetType',
name: 'Target Node',
typeVersion: 1,
});
const nodeTypeDescription = mockNodeTypeDescription({
name: 'targetType',
inputs: [
{
type: NodeConnectionType.Main,
filter: {
nodes: ['sourceType'],
},
},
],
});
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode;
canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode;
expect(
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
).toBe(true);
});
it('should return true if all conditions are met and no filter is set', () => {
const sourceNode = mockNode({
id: '1',
type: 'sourceType',
name: 'Source Node',
typeVersion: 1,
});
const targetNode = mockNode({
id: '2',
type: 'targetType',
name: 'Target Node',
typeVersion: 1,
});
const nodeTypeDescription = mockNodeTypeDescription({
name: 'targetType',
inputs: [
{
type: NodeConnectionType.Main,
},
],
});
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode;
canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode;
expect(
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
).toBe(true);
});
});
describe('deleteConnection', () => {
it('should not delete a connection if source node does not exist', () => {
const removeConnectionSpy = vi

View File

@ -7,24 +7,27 @@ import { mock } from 'vitest-mock-extended';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
import type { IWorkflowDb } from '@/Interface';
import { createTestWorkflowObject, mockNode, mockNodes } from '@/__tests__/mocks';
import { MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
vi.mock('@/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({
getNodeType: vi.fn(() => ({
name: 'test',
description: 'Test Node Description',
})),
isTriggerNode: vi.fn(),
isConfigNode: vi.fn(),
isConfigurableNode: vi.fn(),
})),
}));
import {
createTestWorkflowObject,
mockNode,
mockNodes,
mockNodeTypeDescription,
} from '@/__tests__/mocks';
import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
beforeEach(() => {
const pinia = createPinia();
setActivePinia(pinia);
useNodeTypesStore().setNodeTypes([
mockNodeTypeDescription({
name: MANUAL_TRIGGER_NODE_TYPE,
}),
mockNodeTypeDescription({
name: SET_NODE_TYPE,
}),
]);
});
afterEach(() => {
@ -75,13 +78,41 @@ describe('useCanvasMapping', () => {
type: manualTriggerNode.type,
typeVersion: expect.anything(),
disabled: false,
inputs: [],
outputs: [],
execution: {
status: 'new',
waiting: undefined,
},
issues: {
items: [],
visible: false,
},
pinnedData: {
count: 0,
visible: false,
},
runData: {
count: 0,
visible: false,
},
inputs: [
{
index: 0,
label: undefined,
type: 'main',
},
],
outputs: [
{
index: 0,
label: undefined,
type: 'main',
},
],
connections: {
input: {},
output: {},
},
renderType: 'default',
renderType: 'trigger',
},
},
]);
@ -173,6 +204,7 @@ describe('useCanvasMapping', () => {
index: 0,
type: NodeConnectionType.Main,
},
status: undefined,
target: {
index: 0,
type: NodeConnectionType.Main,
@ -219,6 +251,7 @@ describe('useCanvasMapping', () => {
index: 0,
type: NodeConnectionType.AiTool,
},
status: undefined,
target: {
index: 0,
type: NodeConnectionType.AiTool,
@ -239,6 +272,7 @@ describe('useCanvasMapping', () => {
index: 0,
type: NodeConnectionType.AiDocument,
},
status: undefined,
target: {
index: 1,
type: NodeConnectionType.AiDocument,

View File

@ -1,9 +1,16 @@
/**
* Canvas V2 Only
* @TODO Remove this notice when Canvas V2 is the only one in use
*/
import { useI18n } from '@/composables/useI18n';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { Ref } from 'vue';
import { computed } from 'vue';
import type {
CanvasConnection,
CanvasConnectionData,
CanvasConnectionPort,
CanvasElement,
CanvasElementData,
@ -12,9 +19,17 @@ import {
mapLegacyConnectionsToCanvasConnections,
mapLegacyEndpointsToCanvasConnectionPort,
} from '@/utils/canvasUtilsV2';
import type { Workflow } from 'n8n-workflow';
import type {
ExecutionStatus,
ExecutionSummary,
INodeExecutionData,
ITaskData,
Workflow,
} from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import type { IWorkflowDb } from '@/Interface';
import { WAIT_TIME_UNLIMITED } from '@/constants';
import { sanitizeHtml } from '@/utils/htmlUtils';
export function useCanvasMapping({
workflow,
@ -23,7 +38,8 @@ export function useCanvasMapping({
workflow: Ref<IWorkflowDb>;
workflowObject: Ref<Workflow>;
}) {
const locale = useI18n();
const i18n = useI18n();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const renderTypeByNodeType = computed(
@ -87,6 +103,97 @@ export function useCanvasMapping({
}, {}),
);
const nodePinnedDataById = computed(() =>
workflow.value.nodes.reduce<Record<string, INodeExecutionData[] | undefined>>((acc, node) => {
acc[node.id] = workflowsStore.pinDataByNodeName(node.name);
return acc;
}, {}),
);
const nodeExecutionStatusById = computed(() =>
workflow.value.nodes.reduce<Record<string, ExecutionStatus>>((acc, node) => {
acc[node.id] =
workflowsStore.getWorkflowRunData?.[node.name]?.filter(Boolean)[0].executionStatus ?? 'new';
return acc;
}, {}),
);
const nodeExecutionRunDataById = computed(() =>
workflow.value.nodes.reduce<Record<string, ITaskData[] | null>>((acc, node) => {
acc[node.id] = workflowsStore.getWorkflowResultDataByNodeName(node.name);
return acc;
}, {}),
);
const nodeIssuesById = computed(() =>
workflow.value.nodes.reduce<Record<string, string[]>>((acc, node) => {
const issues: string[] = [];
const nodeExecutionRunData = workflowsStore.getWorkflowRunData?.[node.name];
if (nodeExecutionRunData) {
nodeExecutionRunData.forEach((executionRunData) => {
if (executionRunData?.error) {
const { message, description } = executionRunData.error;
const issue = `${message}${description ? ` (${description})` : ''}`;
issues.push(sanitizeHtml(issue));
}
});
}
if (node?.issues !== undefined) {
issues.push(...NodeHelpers.nodeIssuesToString(node.issues, node));
}
acc[node.id] = issues;
return acc;
}, {}),
);
const nodeHasIssuesById = computed(() =>
workflow.value.nodes.reduce<Record<string, boolean>>((acc, node) => {
if (['crashed', 'error'].includes(nodeExecutionStatusById.value[node.id])) {
acc[node.id] = true;
} else if (nodePinnedDataById.value[node.id]) {
acc[node.id] = false;
} else {
acc[node.id] = Object.keys(node?.issues ?? {}).length > 0;
}
return acc;
}, {}),
);
const nodeExecutionWaitingById = computed(() =>
workflow.value.nodes.reduce<Record<string, string | undefined>>((acc, node) => {
const isExecutionSummary = (execution: object): execution is ExecutionSummary =>
'waitTill' in execution;
const workflowExecution = workflowsStore.getWorkflowExecution;
const lastNodeExecuted = workflowExecution?.data?.resultData?.lastNodeExecuted;
if (workflowExecution && lastNodeExecuted && isExecutionSummary(workflowExecution)) {
if (node.name === workflowExecution.data?.resultData?.lastNodeExecuted) {
const waitDate = new Date(workflowExecution.waitTill as Date);
if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) {
acc[node.id] = i18n.baseText(
'node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall',
);
}
acc[node.id] = i18n.baseText('node.nodeIsWaitingTill', {
interpolate: {
date: waitDate.toLocaleDateString(),
time: waitDate.toLocaleTimeString(),
},
});
}
}
return acc;
}, {}),
);
const elements = computed<CanvasElement[]>(() => [
...workflow.value.nodes.map<CanvasElement>((node) => {
const inputConnections = workflowObject.value.connectionsByDestinationNode[node.name] ?? {};
@ -103,6 +210,22 @@ export function useCanvasMapping({
input: inputConnections,
output: outputConnections,
},
issues: {
items: nodeIssuesById.value[node.id],
visible: nodeHasIssuesById.value[node.id],
},
pinnedData: {
count: nodePinnedDataById.value[node.id]?.length ?? 0,
visible: !!nodePinnedDataById.value[node.id],
},
execution: {
status: nodeExecutionStatusById.value[node.id],
waiting: nodeExecutionWaitingById.value[node.id],
},
runData: {
count: nodeExecutionRunDataById.value[node.id]?.length ?? 0,
visible: !!nodeExecutionRunDataById.value[node.id],
},
renderType: renderTypeByNodeType.value[node.type] ?? 'default',
};
@ -125,26 +248,63 @@ export function useCanvasMapping({
return mappedConnections.map((connection) => {
const type = getConnectionType(connection);
const label = getConnectionLabel(connection);
const data = getConnectionData(connection);
return {
...connection,
data,
type,
label,
};
});
});
function getConnectionData(connection: CanvasConnection): CanvasConnectionData {
const fromNode = workflow.value.nodes.find(
(node) => node.name === connection.data?.fromNodeName,
);
let status: CanvasConnectionData['status'];
if (fromNode) {
if (nodePinnedDataById.value[fromNode.id] && nodeExecutionRunDataById.value[fromNode.id]) {
status = 'pinned';
} else if (nodeHasIssuesById.value[fromNode.id]) {
status = 'error';
} else if (nodeExecutionRunDataById.value[fromNode.id]) {
status = 'success';
}
}
return {
...(connection.data as CanvasConnectionData),
status,
};
}
function getConnectionType(_: CanvasConnection): string {
return 'canvas-edge';
}
function getConnectionLabel(connection: CanvasConnection): string {
const pinData = workflow.value.pinData?.[connection.data?.fromNodeName ?? ''];
const fromNode = workflow.value.nodes.find(
(node) => node.name === connection.data?.fromNodeName,
);
if (pinData?.length) {
return locale.baseText('ndv.output.items', {
adjustToNumber: pinData.length,
interpolate: { count: String(pinData.length) },
if (!fromNode) {
return '';
}
if (nodePinnedDataById.value[fromNode.id]) {
const pinnedDataCount = nodePinnedDataById.value[fromNode.id]?.length ?? 0;
return i18n.baseText('ndv.output.items', {
adjustToNumber: pinnedDataCount,
interpolate: { count: String(pinnedDataCount) },
});
} else if (nodeExecutionRunDataById.value[fromNode.id]) {
const runDataCount = nodeExecutionRunDataById.value[fromNode.id]?.length ?? 0;
return i18n.baseText('ndv.output.items', {
adjustToNumber: runDataCount,
interpolate: { count: String(runDataCount) },
});
}

View File

@ -0,0 +1,73 @@
import { useCanvasNode } from '@/composables/useCanvasNode';
import { inject, ref } from 'vue';
vi.mock('vue', async () => {
const actual = await vi.importActual('vue');
return {
...actual,
inject: vi.fn(),
};
});
describe('useCanvasNode', () => {
it('should return default values when node is not provided', () => {
const result = useCanvasNode();
expect(result.label.value).toBe('');
expect(result.inputs.value).toEqual([]);
expect(result.outputs.value).toEqual([]);
expect(result.connections.value).toEqual({ input: {}, output: {} });
expect(result.isDisabled.value).toBe(false);
expect(result.isSelected.value).toBeUndefined();
expect(result.pinnedDataCount.value).toBe(0);
expect(result.hasPinnedData.value).toBe(false);
expect(result.runDataCount.value).toBe(0);
expect(result.hasRunData.value).toBe(false);
expect(result.issues.value).toEqual([]);
expect(result.hasIssues.value).toBe(false);
expect(result.executionStatus.value).toBeUndefined();
expect(result.executionWaiting.value).toBeUndefined();
});
it('should return node data when node is provided', () => {
const node = {
data: {
value: {
id: 'node1',
type: 'nodeType1',
typeVersion: 1,
disabled: true,
inputs: ['input1'],
outputs: ['output1'],
connections: { input: { '0': ['node2'] }, output: {} },
issues: { items: ['issue1'], visible: true },
execution: { status: 'running', waiting: false },
runData: { count: 1, visible: true },
pinnedData: { count: 1, visible: true },
renderType: 'default',
},
},
label: ref('Node 1'),
selected: ref(true),
};
vi.mocked(inject).mockReturnValue(node);
const result = useCanvasNode();
expect(result.label.value).toBe('Node 1');
expect(result.inputs.value).toEqual(['input1']);
expect(result.outputs.value).toEqual(['output1']);
expect(result.connections.value).toEqual({ input: { '0': ['node2'] }, output: {} });
expect(result.isDisabled.value).toBe(true);
expect(result.isSelected.value).toBe(true);
expect(result.pinnedDataCount.value).toBe(1);
expect(result.hasPinnedData.value).toBe(true);
expect(result.runDataCount.value).toBe(1);
expect(result.hasRunData.value).toBe(true);
expect(result.issues.value).toEqual(['issue1']);
expect(result.hasIssues.value).toBe(true);
expect(result.executionStatus.value).toBe('running');
expect(result.executionWaiting.value).toBe(false);
});
});

View File

@ -0,0 +1,69 @@
/**
* Canvas V2 Only
* @TODO Remove this notice when Canvas V2 is the only one in use
*/
import { CanvasNodeKey } from '@/constants';
import { computed, inject } from 'vue';
import type { CanvasElementData } from '@/types';
export function useCanvasNode() {
const node = inject(CanvasNodeKey);
const data = computed<CanvasElementData>(
() =>
node?.data.value ?? {
id: '',
type: '',
typeVersion: 1,
disabled: false,
inputs: [],
outputs: [],
connections: { input: {}, output: {} },
issues: { items: [], visible: false },
pinnedData: { count: 0, visible: false },
execution: {},
runData: { count: 0, visible: false },
renderType: 'default',
},
);
const label = computed(() => node?.label.value ?? '');
const inputs = computed(() => data.value.inputs);
const outputs = computed(() => data.value.outputs);
const connections = computed(() => data.value.connections);
const isDisabled = computed(() => data.value.disabled);
const isSelected = computed(() => node?.selected.value);
const pinnedDataCount = computed(() => data.value.pinnedData.count);
const hasPinnedData = computed(() => data.value.pinnedData.count > 0);
const issues = computed(() => data.value.issues.items ?? []);
const hasIssues = computed(() => data.value.issues.visible);
const executionStatus = computed(() => data.value.execution.status);
const executionWaiting = computed(() => data.value.execution.waiting);
const runDataCount = computed(() => data.value.runData.count);
const hasRunData = computed(() => data.value.runData.visible);
return {
node,
label,
inputs,
outputs,
connections,
isDisabled,
isSelected,
pinnedDataCount,
hasPinnedData,
runDataCount,
hasRunData,
issues,
hasIssues,
executionStatus,
executionWaiting,
};
}

View File

@ -1,4 +1,10 @@
/**
* Canvas V2 Only
* @TODO Remove this notice when Canvas V2 is the only one in use
*/
import type { CanvasElement } from '@/types';
import { CanvasConnectionMode } from '@/types';
import type {
AddedNodesAndConnections,
INodeUi,
@ -170,9 +176,9 @@ export function useCanvasOperations({
historyStore.startRecordingUndo();
}
workflowsStore.removeNodeById(id);
workflowsStore.removeNodeConnectionsById(id);
workflowsStore.removeNodeExecutionDataById(id);
workflowsStore.removeNodeById(id);
if (trackHistory) {
historyStore.pushCommandToUndo(new RemoveNodeCommand(node));
@ -215,7 +221,7 @@ export function useCanvasOperations({
return;
}
ndvStore.activeNodeName = node.name;
setNodeActiveByName(node.name);
}
function setNodeActiveByName(name: string) {
@ -334,18 +340,32 @@ export function useCanvasOperations({
const outputIndex = lastSelectedNodeOutputIndex ?? 0;
const targetEndpoint = lastSelectedNodeEndpointUuid ?? '';
// Handle connection of scoped_endpoint types
// Create a connection between the last selected node and the new one
if (lastSelectedNode && !options.isAutoAdd) {
// If we have a specific endpoint to connect to
if (lastSelectedNodeEndpointUuid) {
const { type: connectionType } = parseCanvasConnectionHandleString(
const { type: connectionType, mode } = parseCanvasConnectionHandleString(
lastSelectedNodeEndpointUuid,
);
if (isConnectionAllowed(lastSelectedNode, newNodeData, connectionType)) {
const newNodeId = newNodeData.id;
const newNodeHandle = `${CanvasConnectionMode.Input}/${connectionType}/0`;
const lasSelectedNodeId = lastSelectedNode.id;
const lastSelectedNodeHandle = targetEndpoint;
if (mode === CanvasConnectionMode.Input) {
createConnection({
source: lastSelectedNode.id,
sourceHandle: targetEndpoint,
target: newNodeData.id,
targetHandle: `inputs/${connectionType}/0`,
source: newNodeId,
sourceHandle: newNodeHandle,
target: lasSelectedNodeId,
targetHandle: lastSelectedNodeHandle,
});
} else {
createConnection({
source: lasSelectedNodeId,
sourceHandle: lastSelectedNodeHandle,
target: newNodeId,
targetHandle: newNodeHandle,
});
}
} else {
@ -510,8 +530,6 @@ export function useCanvasOperations({
canvasStore.newNodeInsertPosition = null;
} else {
let yOffset = 0;
const workflow = workflowsStore.getCurrentWorkflow();
if (lastSelectedConnection) {
const sourceNodeType = nodeTypesStore.getNodeType(
lastSelectedNode.type,
@ -526,7 +544,7 @@ export function useCanvasOperations({
];
const sourceNodeOutputs = NodeHelpers.getNodeOutputs(
workflow,
editableWorkflowObject.value,
lastSelectedNode,
sourceNodeType,
);
@ -553,7 +571,11 @@ export function useCanvasOperations({
// outputs here is to calculate the position, it is fine to assume
// that they have no outputs and are so treated as a regular node
// with only "main" outputs.
outputs = NodeHelpers.getNodeOutputs(workflow, newNodeData, nodeTypeDescription);
outputs = NodeHelpers.getNodeOutputs(
editableWorkflowObject.value,
newNodeData,
nodeTypeDescription,
);
} catch (e) {}
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
const lastSelectedNodeType = nodeTypesStore.getNodeType(
@ -566,13 +588,15 @@ export function useCanvasOperations({
outputTypes.length > 0 &&
outputTypes.every((outputName) => outputName !== NodeConnectionType.Main)
) {
const lastSelectedNodeWorkflow = workflow.getNode(lastSelectedNode.name);
const lastSelectedNodeWorkflow = editableWorkflowObject.value.getNode(
lastSelectedNode.name,
);
if (!lastSelectedNodeWorkflow || !lastSelectedNodeType) {
return;
}
const lastSelectedInputs = NodeHelpers.getNodeInputs(
workflow,
editableWorkflowObject.value,
lastSelectedNodeWorkflow,
lastSelectedNodeType,
);
@ -600,7 +624,7 @@ export function useCanvasOperations({
// Has only main outputs or no outputs at all
const inputs = NodeHelpers.getNodeInputs(
workflow,
editableWorkflowObject.value,
lastSelectedNode,
lastSelectedNodeType,
);
@ -683,10 +707,11 @@ export function useCanvasOperations({
{ trackHistory = false }: { trackHistory?: boolean },
) {
const sourceNode = workflowsStore.nodesByName[sourceNodeName];
const workflow = workflowHelpers.getCurrentWorkflow();
const checkNodes = workflowHelpers.getConnectedNodes('downstream', workflow, sourceNodeName);
const checkNodes = workflowHelpers.getConnectedNodes(
'downstream',
editableWorkflowObject.value,
sourceNodeName,
);
for (const nodeName of checkNodes) {
const node = workflowsStore.nodesByName[nodeName];
const oldPosition = node.position;
@ -784,6 +809,9 @@ export function useCanvasOperations({
connection: mappedConnection,
});
nodeHelpers.updateNodeInputIssues(sourceNode);
nodeHelpers.updateNodeInputIssues(targetNode);
uiStore.stateIsDirty = true;
}
@ -829,46 +857,54 @@ export function useCanvasOperations({
function isConnectionAllowed(
sourceNode: INodeUi,
targetNode: INodeUi,
targetNodeConnectionType: NodeConnectionType,
connectionType: NodeConnectionType,
): boolean {
const targetNodeType = nodeTypesStore.getNodeType(targetNode.type, targetNode.typeVersion);
if (sourceNode.id === targetNode.id) {
return false;
}
const targetNodeType = nodeTypesStore.getNodeType(targetNode.type, targetNode.typeVersion);
if (targetNodeType?.inputs?.length) {
const workflow = workflowsStore.getCurrentWorkflow();
const workflowNode = workflow.getNode(targetNode.name);
const workflowNode = editableWorkflowObject.value.getNode(targetNode.name);
if (!workflowNode) {
return false;
}
let inputs: Array<ConnectionTypes | INodeInputConfiguration> = [];
if (targetNodeType) {
inputs = NodeHelpers.getNodeInputs(workflow, workflowNode, targetNodeType) || [];
inputs =
NodeHelpers.getNodeInputs(editableWorkflowObject.value, workflowNode, targetNodeType) ||
[];
}
let targetHasConnectionTypeAsInput = false;
for (const input of inputs) {
if (typeof input === 'string' || input.type !== targetNodeConnectionType || !input.filter) {
// No filters defined or wrong connection type
continue;
}
const inputType = typeof input === 'string' ? input : input.type;
if (inputType === connectionType) {
if (typeof input === 'object' && 'filter' in input && input.filter?.nodes.length) {
if (!input.filter.nodes.includes(sourceNode.type)) {
// this.dropPrevented = true;
toast.showToast({
title: i18n.baseText('nodeView.showError.nodeNodeCompatible.title'),
message: i18n.baseText('nodeView.showError.nodeNodeCompatible.message', {
interpolate: { sourceNodeName: sourceNode.name, targetNodeName: targetNode.name },
}),
type: 'error',
duration: 5000,
});
if (input.filter.nodes.length) {
if (!input.filter.nodes.includes(sourceNode.type)) {
// this.dropPrevented = true;
toast.showToast({
title: i18n.baseText('nodeView.showError.nodeNodeCompatible.title'),
message: i18n.baseText('nodeView.showError.nodeNodeCompatible.message', {
interpolate: { sourceNodeName: sourceNode.name, targetNodeName: targetNode.name },
}),
type: 'error',
duration: 5000,
});
return false;
return false;
}
}
targetHasConnectionTypeAsInput = true;
}
}
return targetHasConnectionTypeAsInput;
}
return sourceNode.id !== targetNode.id;
return false;
}
async function addConnections(
@ -907,5 +943,6 @@ export function useCanvasOperations({
createConnection,
deleteConnection,
revertDeleteConnection,
isConnectionAllowed,
};
}

View File

@ -229,22 +229,27 @@ export function useNodeHelpers() {
};
}
function updateNodeInputIssues(node: INodeUi): void {
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (!nodeType) {
return;
}
const workflow = workflowsStore.getCurrentWorkflow();
const nodeInputIssues = getNodeInputIssues(workflow, node, nodeType);
workflowsStore.setNodeIssue({
node: node.name,
type: 'input',
value: nodeInputIssues?.input ? nodeInputIssues.input : null,
});
}
function updateNodesInputIssues() {
const nodes = workflowsStore.allNodes;
const workflow = workflowsStore.getCurrentWorkflow();
for (const node of nodes) {
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (!nodeType) {
return;
}
const nodeInputIssues = getNodeInputIssues(workflow, node, nodeType);
workflowsStore.setNodeIssue({
node: node.name,
type: 'input',
value: nodeInputIssues?.input ? nodeInputIssues.input : null,
});
updateNodeInputIssues(node);
}
}
@ -260,6 +265,14 @@ export function useNodeHelpers() {
}
}
function updateNodesParameterIssues() {
const nodes = workflowsStore.allNodes;
for (const node of nodes) {
updateNodeParameterIssues(node);
}
}
function updateNodeCredentialIssuesByName(name: string): void {
const node = workflowsStore.getNodeByName(name);
@ -1228,6 +1241,8 @@ export function useNodeHelpers() {
getNodeIssues,
updateNodesInputIssues,
updateNodesExecutionIssues,
updateNodesParameterIssues,
updateNodeInputIssues,
updateNodeCredentialIssuesByName,
updateNodeCredentialIssues,
updateNodeParameterIssuesByName,

View File

@ -1050,8 +1050,12 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
}
}
async function initState(workflowData: IWorkflowDb): Promise<void> {
async function initState(workflowData: IWorkflowDb, set = false): Promise<void> {
workflowsStore.addWorkflow(workflowData);
if (set) {
workflowsStore.setWorkflow(workflowData);
}
workflowsStore.setActive(workflowData.active || false);
workflowsStore.setWorkflowId(workflowData.id);
workflowsStore.setWorkflowName({

View File

@ -451,6 +451,7 @@ export const enum VIEWS {
CREDENTIALS = 'CredentialsView',
VARIABLES = 'VariablesView',
NEW_WORKFLOW = 'NodeViewNew',
NEW_WORKFLOW_V2 = 'NodeViewNewV2',
WORKFLOW = 'NodeViewExisting',
WORKFLOW_V2 = 'NodeViewV2',
DEMO = 'WorkflowDemo',
@ -489,8 +490,9 @@ export const enum VIEWS {
export const EDITABLE_CANVAS_VIEWS = [
VIEWS.WORKFLOW,
VIEWS.NEW_WORKFLOW,
VIEWS.EXECUTION_DEBUG,
VIEWS.WORKFLOW_V2,
VIEWS.NEW_WORKFLOW_V2,
VIEWS.EXECUTION_DEBUG,
];
export const enum FAKE_DOOR_FEATURES {

View File

@ -70,6 +70,10 @@ function getTemplatesRedirect(defaultRedirect: VIEWS[keyof VIEWS]): { name: stri
return false;
}
function nodeViewV2CustomMiddleware() {
return !!localStorage.getItem('features.NodeViewV2');
}
export const routes: RouteRecordRaw[] = [
{
path: '/',
@ -367,13 +371,29 @@ export const routes: RouteRecordRaw[] = [
sidebar: MainSidebar,
},
meta: {
nodeView: true,
keepWorkflowAlive: true,
middleware: ['authenticated', 'custom'],
middlewareOptions: {
custom: () => {
return !!localStorage.getItem('features.NodeViewV2');
},
custom: nodeViewV2CustomMiddleware,
},
},
},
{
path: '/workflow-v2/new',
name: VIEWS.NEW_WORKFLOW_V2,
components: {
default: NodeViewV2,
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
nodeView: true,
keepWorkflowAlive: true,
middleware: ['authenticated', 'custom'],
middlewareOptions: {
custom: nodeViewV2CustomMiddleware,
},
},
},
{

View File

@ -31,7 +31,6 @@ import type {
WorkflowMetadata,
IExecutionFlattedResponse,
IWorkflowTemplateNode,
ITag,
} from '@/Interface';
import { defineStore } from 'pinia';
import type {
@ -74,7 +73,6 @@ import { i18n } from '@/plugins/i18n';
import { computed, ref } from 'vue';
import { useProjectsStore } from '@/stores/projects.store';
import { useTagsStore } from '@/stores/tags.store';
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
name: '',
@ -1524,31 +1522,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
clearNodeExecutionData(node.name);
}
function initializeEditableWorkflow(id: string) {
const targetWorkflow = workflowsById.value[id];
const tags = (targetWorkflow?.tags ?? []) as ITag[];
const tagIds = tags.map((tag) => tag.id);
addWorkflow(targetWorkflow);
setWorkflow(targetWorkflow);
setActive(targetWorkflow.active || false);
setWorkflowId(targetWorkflow.id);
setWorkflowName({ newName: targetWorkflow.name, setStateDirty: false });
setWorkflowSettings(targetWorkflow.settings ?? {});
setWorkflowPinData(targetWorkflow.pinData ?? {});
setWorkflowVersionId(targetWorkflow.versionId);
setWorkflowMetadata(targetWorkflow.meta);
if (targetWorkflow.usedCredentials) {
setUsedCredentials(targetWorkflow.usedCredentials);
}
setWorkflowTagIds(tagIds || []);
if (tags.length > 0) {
const tagsStore = useTagsStore();
tagsStore.upsertTags(tags);
}
}
//
// End Canvas V2 Functions
//
@ -1689,6 +1662,5 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
removeNodeExecutionDataById,
setNodes,
setConnections,
initializeEditableWorkflow,
};
});

View File

@ -6,13 +6,13 @@
content: 'n8n supports all JavaScript functions, including those not listed.';
}
.code-node-editor .ͼ2 .cm-tooltip-autocomplete > ul[role='listbox'] {
.code-node-editor .cm-editor .cm-tooltip-autocomplete > ul[role='listbox'] {
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.ͼ2 .cm-tooltip-autocomplete {
.cm-editor .cm-tooltip-autocomplete {
background-color: var(--color-background-xlight) !important;
box-shadow: var(--box-shadow-light);
border: none;
@ -94,9 +94,9 @@
}
}
.ͼ2 .cm-tooltip.cm-completionInfo,
.ͼ2 .cm-tooltip.cm-cursorInfo,
.ͼ2 .cm-tooltip-hover {
.cm-editor .cm-tooltip.cm-completionInfo,
.cm-editor .cm-tooltip.cm-cursorInfo,
.cm-editor .cm-tooltip-hover {
// Add padding when infobox only contains text
&:not(:has(div)) {
padding: var(--spacing-xs);
@ -264,7 +264,7 @@
}
}
.ͼ2 .cm-tooltip.cm-completionInfo {
.cm-editor .cm-tooltip.cm-completionInfo {
background-color: var(--color-background-xlight);
border: var(--border-base);
box-shadow: var(--box-shadow-light);
@ -305,8 +305,8 @@
}
}
.ͼ2 .cm-tooltip.cm-cursorInfo,
.ͼ2 .cm-tooltip-hover {
.cm-editor .cm-tooltip.cm-cursorInfo,
.cm-editor .cm-tooltip-hover {
background-color: var(--color-infobox-background);
border: var(--border-base);
box-shadow: var(--box-shadow-light);

View File

@ -1,5 +1,10 @@
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
import type { ConnectionTypes, INodeConnections, INodeTypeDescription } from 'n8n-workflow';
import type {
ConnectionTypes,
ExecutionStatus,
INodeConnections,
INodeTypeDescription,
} from 'n8n-workflow';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import type { DefaultEdge, Node, NodeProps, Position } from '@vue-flow/core';
import type { INodeUi } from '@/Interface';
@ -9,6 +14,16 @@ export type CanvasElementType = 'node' | 'note';
export type CanvasConnectionPortType = ConnectionTypes;
export const enum CanvasConnectionMode {
Input = 'inputs',
Output = 'outputs',
}
export const canvasConnectionModes = [
CanvasConnectionMode.Input,
CanvasConnectionMode.Output,
] as const;
export type CanvasConnectionPort = {
type: CanvasConnectionPortType;
required?: boolean;
@ -32,6 +47,22 @@ export interface CanvasElementData {
input: INodeConnections;
output: INodeConnections;
};
issues: {
items: string[];
visible: boolean;
};
pinnedData: {
count: number;
visible: boolean;
};
execution: {
status?: ExecutionStatus;
waiting?: string;
};
runData: {
count: number;
visible: boolean;
};
renderType: 'default' | 'trigger' | 'configuration' | 'configurable';
}
@ -41,6 +72,7 @@ export interface CanvasConnectionData {
source: CanvasConnectionPort;
target: CanvasConnectionPort;
fromNodeName?: string;
status?: 'success' | 'error' | 'pinned';
}
export type CanvasConnection = DefaultEdge<CanvasConnectionData>;

View File

@ -428,10 +428,11 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
describe('parseCanvasConnectionHandleString', () => {
it('should parse valid handle string', () => {
const handle = 'outputs/main/1';
const handle = 'inputs/main/1';
const result = parseCanvasConnectionHandleString(handle);
expect(result).toEqual({
mode: 'inputs',
type: 'main',
index: 1,
});
@ -442,6 +443,7 @@ describe('parseCanvasConnectionHandleString', () => {
const result = parseCanvasConnectionHandleString(handle);
expect(result).toEqual({
mode: 'outputs',
type: 'main',
index: 0,
});
@ -452,6 +454,7 @@ describe('parseCanvasConnectionHandleString', () => {
const result = parseCanvasConnectionHandleString(handle);
expect(result).toEqual({
mode: 'outputs',
type: 'main',
index: 0,
});
@ -462,6 +465,7 @@ describe('parseCanvasConnectionHandleString', () => {
const result = parseCanvasConnectionHandleString(handle);
expect(result).toEqual({
mode: 'outputs',
type: 'main',
index: 1,
});
@ -472,6 +476,7 @@ describe('parseCanvasConnectionHandleString', () => {
const result = parseCanvasConnectionHandleString(handle);
expect(result).toEqual({
mode: 'outputs',
type: 'main',
index: 0,
});

View File

@ -1,9 +1,10 @@
import type { IConnection, IConnections, INodeTypeDescription } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import type { CanvasConnection, CanvasConnectionPortType, CanvasConnectionPort } from '@/types';
import { CanvasConnectionMode } from '@/types';
import type { Connection } from '@vue-flow/core';
import { v4 as uuid } from 'uuid';
import { isValidNodeConnectionType } from '@/utils/typeGuards';
import { isValidCanvasConnectionMode, isValidNodeConnectionType } from '@/utils/typeGuards';
import { NodeConnectionType } from 'n8n-workflow';
export function mapLegacyConnectionsToCanvasConnections(
@ -29,8 +30,8 @@ export function mapLegacyConnectionsToCanvasConnections(
id: `[${fromId}/${fromConnectionType}/${fromIndex}][${toId}/${toConnectionType}/${toIndex}]`,
source: fromId,
target: toId,
sourceHandle: `outputs/${fromConnectionType}/${fromIndex}`,
targetHandle: `inputs/${toConnectionType}/${toIndex}`,
sourceHandle: `${CanvasConnectionMode.Output}/${fromConnectionType}/${fromIndex}`,
targetHandle: `${CanvasConnectionMode.Input}/${toConnectionType}/${toIndex}`,
data: {
fromNodeName,
source: {
@ -53,9 +54,10 @@ export function mapLegacyConnectionsToCanvasConnections(
}
export function parseCanvasConnectionHandleString(handle: string | null | undefined) {
const [, type, index] = (handle ?? '').split('/');
const [mode, type, index] = (handle ?? '').split('/');
const resolvedType = isValidNodeConnectionType(type) ? type : NodeConnectionType.Main;
const resolvedMode = isValidCanvasConnectionMode(mode) ? mode : CanvasConnectionMode.Output;
let resolvedIndex = parseInt(index, 10);
if (isNaN(resolvedIndex)) {
@ -63,6 +65,7 @@ export function parseCanvasConnectionHandleString(handle: string | null | undefi
}
return {
mode: resolvedMode,
type: resolvedType,
index: resolvedIndex,
};

View File

@ -9,6 +9,8 @@ import type { IExecutionResponse, ICredentialsResponse, NewCredentialsModal } fr
import type { jsPlumbDOMElement } from '@jsplumb/browser-ui';
import type { Connection } from '@jsplumb/core';
import type { RouteLocationRaw } from 'vue-router';
import type { CanvasConnectionMode } from '@/types';
import { canvasConnectionModes } from '@/types';
/*
Type guards used in editor-ui project
@ -69,6 +71,10 @@ export function isValidNodeConnectionType(
return nodeConnectionTypes.includes(connectionType as NodeConnectionType);
}
export function isValidCanvasConnectionMode(mode: string): mode is CanvasConnectionMode {
return canvasConnectionModes.includes(mode as CanvasConnectionMode);
}
export function isTriggerPanelObject(
triggerPanel: INodeTypeDescription['triggerPanel'],
): triggerPanel is TriggerPanelDefinition {

View File

@ -1,5 +1,14 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, useCssModule } from 'vue';
import {
computed,
defineAsyncComponent,
nextTick,
onBeforeMount,
onBeforeUnmount,
onMounted,
ref,
useCssModule,
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@ -13,6 +22,7 @@ import type {
INodeUi,
IUpdateInformation,
IWorkflowDataUpdate,
IWorkflowDb,
ToggleNodeCreatorOptions,
XYPosition,
} from '@/Interface';
@ -21,15 +31,19 @@ import type { CanvasElement } from '@/types';
import {
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT,
EnterpriseEditionFeature,
MODAL_CANCEL,
MODAL_CONFIRM,
NEW_WORKFLOW_ID,
VIEWS,
} from '@/constants';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useExternalHooks } from '@/composables/useExternalHooks';
import type { NodeConnectionType, ExecutionSummary, IConnection } from 'n8n-workflow';
import { TelemetryHelpers } from 'n8n-workflow';
import type {
NodeConnectionType,
ExecutionSummary,
IConnection,
IWorkflowBase,
} from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { useSettingsStore } from '@/stores/settings.store';
import { useCredentialsStore } from '@/stores/credentials.store';
@ -48,6 +62,14 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useHistoryStore } from '@/stores/history.store';
import { useProjectsStore } from '@/stores/projects.store';
import { usePostHog } from '@/stores/posthog.store';
import useWorkflowsEEStore from '@/stores/workflows.ee.store';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
import type { ProjectSharingData } from '@/types/projects.types';
import { useUsersStore } from '@/stores/users.store';
import { sourceControlEventBus } from '@/event-bus/source-control';
import { useTagsStore } from '@/stores/tags.store';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
const NodeCreation = defineAsyncComponent(
async () => await import('@/components/Node/NodeCreation.vue'),
@ -67,10 +89,13 @@ const toast = useToast();
const message = useMessage();
const titleChange = useTitleChange();
const workflowHelpers = useWorkflowHelpers({ router });
const nodeHelpers = useNodeHelpers();
const posthog = usePostHog();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const workflowsEEStore = useWorkflowsEEStore();
const sourceControlStore = useSourceControlStore();
const nodeCreatorStore = useNodeCreatorStore();
const settingsStore = useSettingsStore();
@ -83,6 +108,9 @@ const canvasStore = useCanvasStore();
const npsSurveyStore = useNpsSurveyStore();
const historyStore = useHistoryStore();
const projectsStore = useProjectsStore();
const usersStore = useUsersStore();
const tagsStore = useTagsStore();
const pushConnectionStore = usePushConnectionStore();
const lastClickPosition = ref<XYPosition>([450, 450]);
@ -105,6 +133,7 @@ const {
editableWorkflow,
editableWorkflowObject,
} = useCanvasOperations({ router, lastClickPosition });
const { applyExecutionData } = useExecutionDebugging();
const isLoading = ref(true);
const isBlankRedirect = ref(false);
@ -120,6 +149,7 @@ const hideNodeIssues = ref(false);
const workflowId = computed<string>(() => route.params.workflowId as string);
const workflow = computed(() => workflowsStore.workflowsById[workflowId.value]);
const isNewWorkflowRoute = computed(() => route.name === VIEWS.NEW_WORKFLOW_V2);
const isDemoRoute = computed(() => route.name === VIEWS.DEMO);
const isReadOnlyRoute = computed(() => route?.meta?.readOnlyCanvas === true);
const isReadOnlyEnvironment = computed(() => {
@ -132,58 +162,61 @@ const isReadOnlyEnvironment = computed(() => {
async function initializeData() {
isLoading.value = true;
canvasStore.startLoading();
resetWorkspace();
titleChange.titleReset();
const loadPromises: Array<Promise<unknown>> = [
nodeTypesStore.getNodeTypes(),
workflowsStore.fetchWorkflow(workflowId.value),
];
const loadPromises = (() => {
if (settingsStore.isPreviewMode && isDemoRoute.value) return [];
if (!settingsStore.isPreviewMode && !isDemoRoute.value) {
loadPromises.push(
const promises: Array<Promise<unknown>> = [
workflowsStore.fetchActiveWorkflows(),
credentialsStore.fetchAllCredentials(),
credentialsStore.fetchCredentialTypes(true),
);
];
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Variables)) {
loadPromises.push(environmentsStore.fetchAllVariables());
promises.push(environmentsStore.fetchAllVariables());
}
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.ExternalSecrets)) {
loadPromises.push(externalSecretsStore.fetchAllSecrets());
promises.push(externalSecretsStore.fetchAllSecrets());
}
}
try {
await Promise.all(loadPromises);
} catch (error) {
return toast.showError(
error,
i18n.baseText('nodeView.showError.mounted1.title'),
i18n.baseText('nodeView.showError.mounted1.message') + ':',
);
}
if (nodeTypesStore.allNodeTypes.length === 0) {
promises.push(nodeTypesStore.getNodeTypes());
}
void externalHooks.run('workflow.open', {
workflowId: workflowsStore.workflow.id,
workflowName: workflowsStore.workflow.name,
});
const selectedExecution = executionsStore.activeExecution;
if (selectedExecution?.workflowId !== workflowsStore.workflow.id) {
executionsStore.activeExecution = null;
workflowsStore.currentWorkflowExecutions = [];
} else {
executionsStore.activeExecution = selectedExecution;
}
return promises;
})();
// @TODO Implement this
// this.clipboard.onPaste.value = this.onClipboardPasteEvent;
isLoading.value = false;
try {
await Promise.all(loadPromises);
} catch (error) {
toast.showError(
error,
i18n.baseText('nodeView.showError.mounted1.title'),
i18n.baseText('nodeView.showError.mounted1.message') + ':',
);
return;
} finally {
canvasStore.stopLoading();
isLoading.value = false;
}
setTimeout(() => {
void usersStore.showPersonalizationSurvey();
}, 0);
// @TODO: This currently breaks since front-end hooks are still not updated to work with pinia store
void externalHooks.run('nodeView.mount').catch(() => {});
// @TODO maybe we can find a better way to handle this
canvasStore.isDemo = isDemoRoute.value;
}
async function initializeView() {
@ -205,28 +238,6 @@ async function initializeView() {
// const templateId = route.params.id;
// await openWorkflowTemplate(templateId.toString());
} else {
if (uiStore.stateIsDirty && !isReadOnlyEnvironment.value) {
const confirmModal = await message.confirm(
i18n.baseText('generic.unsavedWork.confirmMessage.message'),
{
title: i18n.baseText('generic.unsavedWork.confirmMessage.headline'),
type: 'warning',
confirmButtonText: i18n.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
cancelButtonText: i18n.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
showClose: true,
},
);
if (confirmModal === MODAL_CONFIRM) {
const saved = await workflowHelpers.saveCurrentWorkflow();
if (saved) {
await npsSurveyStore.fetchPromptsData();
}
} else if (confirmModal === MODAL_CANCEL) {
return;
}
}
// Get workflow id
let workflowIdParam: string | null = null;
if (route.params.workflowId) {
@ -236,7 +247,7 @@ async function initializeView() {
historyStore.reset();
// If there is no workflow id, treat it as a new workflow
if (!workflowIdParam || workflowIdParam === NEW_WORKFLOW_ID) {
if (!workflowIdParam || isNewWorkflowRoute.value) {
if (route.meta?.nodeView === true) {
await initializeViewForNewWorkflow();
}
@ -248,24 +259,25 @@ async function initializeView() {
await workflowsStore.fetchWorkflow(workflowIdParam);
titleChange.titleSet(workflow.value.name, 'IDLE');
// @TODO Implement this
// await openWorkflow(workflow);
// await checkAndInitDebugMode();
workflowsStore.initializeEditableWorkflow(workflowIdParam);
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflow.value.homeProject);
await openWorkflow(workflow.value);
await checkAndInitDebugMode();
trackOpenWorkflowFromOnboardingTemplate();
} catch (error) {
toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError'));
void router.push({
name: VIEWS.NEW_WORKFLOW,
name: VIEWS.NEW_WORKFLOW_V2,
});
}
}
nodeHelpers.updateNodesInputIssues();
nodeHelpers.updateNodesCredentialsIssues();
nodeHelpers.updateNodesParameterIssues();
await loadCredentials();
uiStore.nodeViewInitialized = true;
// Once view is initialized, pick up all toast notifications
@ -284,53 +296,113 @@ async function initializeViewForNewWorkflow() {
uiStore.nodeViewInitialized = true;
executionsStore.activeExecution = null;
// @TODO Implement this
// canvasStore.setZoomLevel(1, [0, 0]);
// canvasStore.zoomToFit();
makeNewWorkflowShareable();
await runAutoAddManualTriggerExperiment();
}
// @TODO Implement this
// this.makeNewWorkflowShareable();
// Pre-populate the canvas with the manual trigger node if the experiment is enabled and the user is in the variant group
const { getVariant } = usePostHog();
/**
* Pre-populate the canvas with the manual trigger node
* if the experiment is enabled and the user is in the variant group
*/
async function runAutoAddManualTriggerExperiment() {
if (
getVariant(CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.name) ===
posthog.getVariant(CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.name) !==
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.variant
) {
const manualTriggerNode = canvasStore.getAutoAddManualTriggerNode();
if (manualTriggerNode) {
await addNodes([manualTriggerNode]);
uiStore.lastSelectedNode = manualTriggerNode.name;
return;
}
const manualTriggerNode = canvasStore.getAutoAddManualTriggerNode();
if (manualTriggerNode) {
await addNodes([manualTriggerNode]);
uiStore.lastSelectedNode = manualTriggerNode.name;
}
}
// @ts-expect-error @TODO Add binding on route leave
async function promptSaveOnBeforeRouteLeave() {
if (uiStore.stateIsDirty && !isReadOnlyEnvironment.value) {
const confirmModal = await message.confirm(
i18n.baseText('generic.unsavedWork.confirmMessage.message'),
{
title: i18n.baseText('generic.unsavedWork.confirmMessage.headline'),
type: 'warning',
confirmButtonText: i18n.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
cancelButtonText: i18n.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
showClose: true,
},
);
if (confirmModal === MODAL_CONFIRM) {
const saved = await workflowHelpers.saveCurrentWorkflow();
if (saved) {
await npsSurveyStore.fetchPromptsData();
}
}
}
}
function resetWorkspace() {
workflowsStore.resetWorkflow();
onToggleNodeCreator({ createNodeActive: false });
nodeCreatorStore.setShowScrim(false);
// @TODO Implement this
// Reset nodes
// this.unbindEndpointEventListeners();
// this.deleteEveryEndpoint();
// Make sure that if there is a waiting test-webhook that it gets removed
if (isExecutionWaitingForWebhook.value) {
try {
void workflowsStore.removeTestWebhook(workflowsStore.workflowId);
} catch (error) {}
}
workflowsStore.resetWorkflow();
workflowsStore.resetState();
uiStore.removeActiveAction('workflowRunning');
uiStore.removeActiveAction('workflowRunning');
uiStore.resetSelectedNodes();
uiStore.nodeViewOffsetPosition = [0, 0]; // @TODO Not sure if needed
// this.credentialsUpdated = false;
}
/**
* Workflow
*/
async function openWorkflow(data: IWorkflowDb) {
const selectedExecution = executionsStore.activeExecution;
resetWorkspace();
await workflowHelpers.initState(data, true);
if (data.sharedWithProjects) {
workflowsEEStore.setWorkflowSharedWith({
workflowId: data.id,
sharedWithProjects: data.sharedWithProjects,
});
}
if (data.usedCredentials) {
workflowsStore.setUsedCredentials(data.usedCredentials);
}
if (!nodeHelpers.credentialsUpdated.value) {
uiStore.stateIsDirty = false;
}
void externalHooks.run('workflow.open', {
workflowId: data.id,
workflowName: data.name,
});
if (selectedExecution?.workflowId !== data.id) {
executionsStore.activeExecution = null;
workflowsStore.currentWorkflowExecutions = [];
} else {
executionsStore.activeExecution = selectedExecution;
}
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflow.value.homeProject);
}
function trackOpenWorkflowFromOnboardingTemplate() {
if (workflow.value.meta?.onboardingId) {
telemetry.track(
@ -345,6 +417,15 @@ function trackOpenWorkflowFromOnboardingTemplate() {
}
}
function makeNewWorkflowShareable() {
const { currentProject, personalProject } = projectsStore;
const homeProject = currentProject ?? personalProject ?? {};
const scopes = currentProject?.scopes ?? personalProject?.scopes ?? [];
workflowsStore.workflow.homeProject = homeProject as ProjectSharingData;
workflowsStore.workflow.scopes = scopes;
}
/**
* Nodes
*/
@ -479,27 +560,32 @@ function onToggleNodeCreator(options: ToggleNodeCreatorOptions) {
*/
async function onRunWorkflow() {
trackRunWorkflow();
await runWorkflow({});
}
function trackRunWorkflow() {
void workflowHelpers.getWorkflowDataToSave().then((workflowData) => {
const telemetryPayload = {
workflow_id: workflowId.value,
node_graph_string: JSON.stringify(
TelemetryHelpers.generateNodesGraph(
workflowData as IWorkflowBase,
workflowHelpers.getNodeTypes(),
{ isCloudDeployment: settingsStore.isCloudDeployment },
).nodeGraph,
),
};
telemetry.track('User clicked execute workflow button', telemetryPayload);
void externalHooks.run('nodeView.onRunWorkflow', telemetryPayload);
});
}
async function openExecution(_executionId: string) {
// @TODO
}
/**
* Unload
*/
function addUnloadEventBindings() {
// window.addEventListener('beforeunload', this.onBeforeUnload);
// window.addEventListener('unload', this.onUnload);
}
function removeUnloadEventBindings() {
// window.removeEventListener('beforeunload', this.onBeforeUnload);
// window.removeEventListener('unload', this.onUnload);
}
/**
* Keboard
*/
@ -538,6 +624,38 @@ function removeUndoRedoEventBindings() {
// historyBus.off('enableNodeToggle', onRevertEnableToggle);
}
/**
* Source control
*/
async function onSourceControlPull() {
try {
await Promise.all([
environmentsStore.fetchAllVariables(),
tagsStore.fetchAll(),
loadCredentials(),
]);
if (workflowId.value !== null && !uiStore.stateIsDirty) {
const workflowData = await workflowsStore.fetchWorkflow(workflowId.value);
if (workflowData) {
titleChange.titleSet(workflowData.name, 'IDLE');
await openWorkflow(workflowData);
}
}
} catch (error) {
console.error(error);
}
}
function addSourceControlEventBindings() {
sourceControlEventBus.on('pull', onSourceControlPull);
}
function removeSourceControlEventBindings() {
sourceControlEventBus.off('pull', onSourceControlPull);
}
/**
* Post message events
*/
@ -647,6 +765,34 @@ function checkIfEditingIsAllowed(): boolean {
return true;
}
function checkIfRouteIsAllowed() {
if (
isReadOnlyEnvironment.value &&
[VIEWS.NEW_WORKFLOW, VIEWS.TEMPLATE_IMPORT].find((view) => view === route.name)
) {
void nextTick(async () => {
resetWorkspace();
uiStore.stateIsDirty = false;
await router.replace({ name: VIEWS.HOMEPAGE });
});
}
}
/**
* Debug mode
*/
async function checkAndInitDebugMode() {
if (route.name === VIEWS.EXECUTION_DEBUG) {
titleChange.titleSet(workflowsStore.workflowName, 'DEBUG');
if (!workflowsStore.isInDebugMode) {
await applyExecutionData(route.params.executionId as string);
workflowsStore.isInDebugMode = true;
}
}
}
/**
* Mouse events
*/
@ -656,25 +802,66 @@ function onClickPane(position: CanvasElement['position']) {
canvasStore.newNodeInsertPosition = [position.x, position.y];
}
/**
* Custom Actions
*/
function registerCustomActions() {
// @TODO Implement these
// this.registerCustomAction({
// key: 'openNodeDetail',
// action: ({ node }: { node: string }) => {
// this.nodeSelectedByName(node, true);
// },
// });
//
// this.registerCustomAction({
// key: 'openSelectiveNodeCreator',
// action: this.openSelectiveNodeCreator,
// });
//
// this.registerCustomAction({
// key: 'showNodeCreator',
// action: () => {
// this.ndvStore.activeNodeName = null;
//
// void this.$nextTick(() => {
// this.showTriggerCreator(NODE_CREATOR_OPEN_SOURCES.TAB);
// });
// },
// });
}
/**
* Lifecycle
*/
onBeforeMount(() => {
if (!isDemoRoute.value) {
pushConnectionStore.pushConnect();
}
});
onMounted(async () => {
await initializeData();
await initializeView();
void initializeData().then(() => {
void initializeView();
checkIfRouteIsAllowed();
});
addUndoRedoEventBindings();
addPostMessageEventBindings();
addKeyboardEventBindings();
addUnloadEventBindings();
addSourceControlEventBindings();
registerCustomActions();
});
onBeforeUnmount(() => {
removeUnloadEventBindings();
removeKeyboardEventBindings();
removePostMessageEventBindings();
removeUndoRedoEventBindings();
removeSourceControlEventBindings();
});
</script>

View File

@ -1185,7 +1185,7 @@ importers:
version: 10.5.0(vue@3.4.21(typescript@5.5.2))
axios:
specifier: 1.6.7
version: 1.6.7(debug@3.2.7)
version: 1.6.7
chart.js:
specifier: ^4.4.0
version: 4.4.0
@ -16118,7 +16118,7 @@ snapshots:
'@antfu/install-pkg': 0.1.1
'@antfu/utils': 0.7.6
'@iconify/types': 2.0.0
debug: 4.3.4(supports-color@8.1.1)
debug: 4.3.4
kolorist: 1.8.0
local-pkg: 0.4.3
transitivePeerDependencies:
@ -19962,7 +19962,7 @@ snapshots:
agent-base@6.0.2:
dependencies:
debug: 4.3.4(supports-color@8.1.1)
debug: 4.3.4
transitivePeerDependencies:
- supports-color
@ -20266,6 +20266,14 @@ snapshots:
'@babel/runtime': 7.23.6
is-retry-allowed: 2.2.0
axios@1.6.7:
dependencies:
follow-redirects: 1.15.6
form-data: 4.0.0
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
axios@1.6.7(debug@3.2.7):
dependencies:
follow-redirects: 1.15.6(debug@3.2.7)
@ -21292,6 +21300,10 @@ snapshots:
optionalDependencies:
supports-color: 8.1.1
debug@4.3.4:
dependencies:
ms: 2.1.2
debug@4.3.4(supports-color@8.1.1):
dependencies:
ms: 2.1.2
@ -22466,6 +22478,8 @@ snapshots:
fn.name@1.1.0: {}
follow-redirects@1.15.6: {}
follow-redirects@1.15.6(debug@3.2.7):
optionalDependencies:
debug: 3.2.7(supports-color@5.5.0)
@ -23078,7 +23092,7 @@ snapshots:
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.3.4(supports-color@8.1.1)
debug: 4.3.4
transitivePeerDependencies:
- supports-color
@ -27814,7 +27828,7 @@ snapshots:
'@antfu/install-pkg': 0.1.1
'@antfu/utils': 0.7.6
'@iconify/utils': 2.1.11
debug: 4.3.4(supports-color@8.1.1)
debug: 4.3.4
kolorist: 1.8.0
local-pkg: 0.5.0
unplugin: 1.5.1