1
1
mirror of https://github.com/n8n-io/n8n.git synced 2024-09-11 13:15:28 +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", "@jsplumb/util": "^5.13.2",
"@lezer/common": "^1.0.4", "@lezer/common": "^1.0.4",
"@n8n/chat": "workspace:*", "@n8n/chat": "workspace:*",
"@n8n/codemirror-lang": "workspace:*",
"@n8n/codemirror-lang-sql": "^1.0.2", "@n8n/codemirror-lang-sql": "^1.0.2",
"@n8n/permissions": "workspace:*", "@n8n/permissions": "workspace:*",
"@vue-flow/background": "^1.3.0", "@vue-flow/background": "^1.3.0",
@ -55,7 +56,6 @@
"axios": "1.6.7", "axios": "1.6.7",
"chart.js": "^4.4.0", "chart.js": "^4.4.0",
"codemirror-lang-html-n8n": "^1.0.0", "codemirror-lang-html-n8n": "^1.0.0",
"@n8n/codemirror-lang": "workspace:*",
"dateformat": "^3.0.3", "dateformat": "^3.0.3",
"email-providers": "^2.0.1", "email-providers": "^2.0.1",
"esprima-next": "5.8.4", "esprima-next": "5.8.4",

View File

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

View File

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

View File

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

View File

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

View File

@ -17,12 +17,40 @@ const props = defineProps<
const $style = useCssModule(); 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(() => ({ const edgeStyle = computed(() => ({
strokeWidth: 2,
...props.style, ...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(() => { const edgeToolbarStyle = computed(() => {
return { return {
@ -32,7 +60,7 @@ const edgeToolbarStyle = computed(() => {
const edgeToolbarClasses = computed(() => ({ const edgeToolbarClasses = computed(() => ({
[$style.edgeToolbar]: true, [$style.edgeToolbar]: true,
[$style.edgeToolbarVisible]: isEdgeToolbarVisible.value, [$style.edgeToolbarVisible]: isFocused.value,
nodrag: true, nodrag: true,
nopan: true, nopan: true,
})); }));
@ -63,18 +91,15 @@ function onDelete() {
<template> <template>
<BaseEdge <BaseEdge
:id="id" :id="id"
:class="$style.edge"
:style="edgeStyle" :style="edgeStyle"
:path="path[0]" :path="path[0]"
:marker-end="markerEnd" :marker-end="markerEnd"
:label="data?.label" :label="edgeLabel"
:label-x="path[1]" :label-x="path[1]"
:label-y="path[2]" :label-y="path[2]"
:label-style="{ fill: 'white' }" :label-style="edgeLabelStyle"
:label-show-bg="true" :label-show-bg="false"
:label-bg-style="{ fill: 'red' }"
:label-bg-padding="[2, 4]"
:label-bg-border-radius="2"
:class="$style.edge"
/> />
<EdgeLabelRenderer> <EdgeLabelRenderer>
<CanvasEdgeToolbar :class="edgeToolbarClasses" :style="edgeToolbarStyle" @delete="onDelete" /> <CanvasEdgeToolbar :class="edgeToolbarClasses" :style="edgeToolbarStyle" @delete="onDelete" />
@ -82,6 +107,10 @@ function onDelete() {
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.edge {
transition: stroke 0.3s ease;
}
.edgeToolbar { .edgeToolbar {
position: absolute; position: absolute;
opacity: 0; opacity: 0;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,33 +1,39 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, useCssModule } from 'vue'; import { computed, useCssModule } from 'vue';
import { useNodeConnections } from '@/composables/useNodeConnections'; import { useNodeConnections } from '@/composables/useNodeConnections';
import { CanvasNodeKey } from '@/constants';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import CanvasNodeDisabledStrikeThrough from './parts/CanvasNodeDisabledStrikeThrough.vue'; import CanvasNodeDisabledStrikeThrough from './parts/CanvasNodeDisabledStrikeThrough.vue';
import CanvasNodeStatusIcons from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue';
const node = inject(CanvasNodeKey); import { useCanvasNode } from '@/composables/useCanvasNode';
const $style = useCssModule(); const $style = useCssModule();
const i18n = useI18n(); const i18n = useI18n();
const label = computed(() => node?.label.value ?? ''); const {
label,
const inputs = computed(() => node?.data.value.inputs ?? []); inputs,
const outputs = computed(() => node?.data.value.outputs ?? []); outputs,
const connections = computed(() => node?.data.value.connections ?? { input: {}, output: {} }); connections,
isDisabled,
isSelected,
hasPinnedData,
hasRunData,
hasIssues,
} = useCanvasNode();
const { mainOutputs } = useNodeConnections({ const { mainOutputs } = useNodeConnections({
inputs, inputs,
outputs, outputs,
connections, connections,
}); });
const isDisabled = computed(() => node?.data.value.disabled ?? false);
const classes = computed(() => { const classes = computed(() => {
return { return {
[$style.node]: true, [$style.node]: true,
[$style.selected]: node?.selected.value, [$style.selected]: isSelected.value,
[$style.disabled]: isDisabled.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> </script>
<template> <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 /> <slot />
<CanvasNodeStatusIcons :class="$style.statusIcons" />
<CanvasNodeDisabledStrikeThrough v-if="isDisabled" /> <CanvasNodeDisabledStrikeThrough v-if="isDisabled" />
<div v-if="label" :class="$style.label"> <div v-if="label" :class="$style.label">
{{ label }} {{ label }}
@ -62,6 +69,31 @@ const styles = computed(() => {
background: var(--canvas-node--background, var(--color-node-background)); background: var(--canvas-node--background, var(--color-node-background));
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-xdark)); border: 2px solid var(--canvas-node--border-color, var(--color-foreground-xdark));
border-radius: var(--border-radius-large); 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 { .label {
@ -74,11 +106,9 @@ const styles = computed(() => {
margin-top: var(--spacing-2xs); margin-top: var(--spacing-2xs);
} }
.selected { .statusIcons {
box-shadow: 0 0 0 4px var(--color-canvas-selected); position: absolute;
} top: calc(var(--canvas-node--height) - 24px);
right: var(--spacing-xs);
.disabled {
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
} }
</style> </style>

View File

@ -1,14 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, useCssModule } from 'vue'; import { computed, useCssModule } from 'vue';
import { CanvasNodeKey } from '@/constants';
import { useNodeConnections } from '@/composables/useNodeConnections'; import { useNodeConnections } from '@/composables/useNodeConnections';
import { useCanvasNode } from '@/composables/useCanvasNode';
const $style = useCssModule(); const $style = useCssModule();
const node = inject(CanvasNodeKey);
const inputs = computed(() => node?.data.value.inputs ?? []); const { inputs, outputs, connections } = useCanvasNode();
const outputs = computed(() => node?.data.value.outputs ?? []);
const connections = computed(() => node?.data.value.connections ?? { input: {}, output: {} });
const { mainInputConnections, mainOutputConnections } = useNodeConnections({ const { mainInputConnections, mainOutputConnections } = useNodeConnections({
inputs, inputs,
outputs, 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 { mock } from 'vitest-mock-extended';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
vi.mock('vue-router', async () => { vi.mock('vue-router', async () => {
const actual = await import('vue-router'); const actual = await import('vue-router');
@ -39,11 +40,12 @@ describe('useCanvasOperations', () => {
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>; let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
let credentialsStore: ReturnType<typeof useCredentialsStore>; let credentialsStore: ReturnType<typeof useCredentialsStore>;
let canvasOperations: ReturnType<typeof useCanvasOperations>; let canvasOperations: ReturnType<typeof useCanvasOperations>;
let workflowHelpers: ReturnType<typeof useWorkflowHelpers>;
const lastClickPosition = ref<XYPosition>([450, 450]); const lastClickPosition = ref<XYPosition>([450, 450]);
const router = useRouter(); const router = useRouter();
beforeEach(() => { beforeEach(async () => {
const pinia = createPinia(); const pinia = createPinia();
setActivePinia(pinia); setActivePinia(pinia);
@ -53,15 +55,17 @@ describe('useCanvasOperations', () => {
historyStore = useHistoryStore(); historyStore = useHistoryStore();
nodeTypesStore = useNodeTypesStore(); nodeTypesStore = useNodeTypesStore();
credentialsStore = useCredentialsStore(); credentialsStore = useCredentialsStore();
workflowHelpers = useWorkflowHelpers({ router });
const workflowId = 'test'; const workflowId = 'test';
workflowsStore.workflowsById[workflowId] = mock<IWorkflowDb>({ const workflow = mock<IWorkflowDb>({
id: workflowId, id: workflowId,
nodes: [], nodes: [],
tags: [], tags: [],
usedCredentials: [], usedCredentials: [],
}); });
workflowsStore.initializeEditableWorkflow(workflowId); workflowsStore.workflowsById[workflowId] = workflow;
await workflowHelpers.initState(workflow, true);
canvasOperations = useCanvasOperations({ router, lastClickPosition }); canvasOperations = useCanvasOperations({ router, lastClickPosition });
}); });
@ -506,13 +510,13 @@ describe('useCanvasOperations', () => {
connection: [ connection: [
{ {
index: 0, index: 0,
node: 'Node B', node: 'Node A',
type: 'main', type: NodeConnectionType.Main,
}, },
{ {
index: 0, index: 0,
node: 'spy', node: 'Node B',
type: 'main', type: NodeConnectionType.Main,
}, },
], ],
}); });
@ -567,6 +571,8 @@ describe('useCanvasOperations', () => {
name: 'Node B', name: 'Node B',
}); });
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB);
const connection: Connection = { const connection: Connection = {
source: nodeA.id, source: nodeA.id,
sourceHandle: `outputs/${NodeConnectionType.Main}/0`, sourceHandle: `outputs/${NodeConnectionType.Main}/0`,
@ -574,7 +580,14 @@ describe('useCanvasOperations', () => {
targetHandle: `inputs/${NodeConnectionType.Main}/0`, 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); 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', () => { describe('deleteConnection', () => {
it('should not delete a connection if source node does not exist', () => { it('should not delete a connection if source node does not exist', () => {
const removeConnectionSpy = vi const removeConnectionSpy = vi

View File

@ -7,24 +7,27 @@ import { mock } from 'vitest-mock-extended';
import { useCanvasMapping } from '@/composables/useCanvasMapping'; import { useCanvasMapping } from '@/composables/useCanvasMapping';
import type { IWorkflowDb } from '@/Interface'; import type { IWorkflowDb } from '@/Interface';
import { createTestWorkflowObject, mockNode, mockNodes } from '@/__tests__/mocks'; import {
import { MANUAL_TRIGGER_NODE_TYPE } from '@/constants'; createTestWorkflowObject,
mockNode,
vi.mock('@/stores/nodeTypes.store', () => ({ mockNodes,
useNodeTypesStore: vi.fn(() => ({ mockNodeTypeDescription,
getNodeType: vi.fn(() => ({ } from '@/__tests__/mocks';
name: 'test', import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
description: 'Test Node Description', import { useNodeTypesStore } from '@/stores/nodeTypes.store';
})),
isTriggerNode: vi.fn(),
isConfigNode: vi.fn(),
isConfigurableNode: vi.fn(),
})),
}));
beforeEach(() => { beforeEach(() => {
const pinia = createPinia(); const pinia = createPinia();
setActivePinia(pinia); setActivePinia(pinia);
useNodeTypesStore().setNodeTypes([
mockNodeTypeDescription({
name: MANUAL_TRIGGER_NODE_TYPE,
}),
mockNodeTypeDescription({
name: SET_NODE_TYPE,
}),
]);
}); });
afterEach(() => { afterEach(() => {
@ -75,13 +78,41 @@ describe('useCanvasMapping', () => {
type: manualTriggerNode.type, type: manualTriggerNode.type,
typeVersion: expect.anything(), typeVersion: expect.anything(),
disabled: false, disabled: false,
inputs: [], execution: {
outputs: [], 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: { connections: {
input: {}, input: {},
output: {}, output: {},
}, },
renderType: 'default', renderType: 'trigger',
}, },
}, },
]); ]);
@ -173,6 +204,7 @@ describe('useCanvasMapping', () => {
index: 0, index: 0,
type: NodeConnectionType.Main, type: NodeConnectionType.Main,
}, },
status: undefined,
target: { target: {
index: 0, index: 0,
type: NodeConnectionType.Main, type: NodeConnectionType.Main,
@ -219,6 +251,7 @@ describe('useCanvasMapping', () => {
index: 0, index: 0,
type: NodeConnectionType.AiTool, type: NodeConnectionType.AiTool,
}, },
status: undefined,
target: { target: {
index: 0, index: 0,
type: NodeConnectionType.AiTool, type: NodeConnectionType.AiTool,
@ -239,6 +272,7 @@ describe('useCanvasMapping', () => {
index: 0, index: 0,
type: NodeConnectionType.AiDocument, type: NodeConnectionType.AiDocument,
}, },
status: undefined,
target: { target: {
index: 1, index: 1,
type: NodeConnectionType.AiDocument, 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 { useI18n } from '@/composables/useI18n';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import { computed } from 'vue'; import { computed } from 'vue';
import type { import type {
CanvasConnection, CanvasConnection,
CanvasConnectionData,
CanvasConnectionPort, CanvasConnectionPort,
CanvasElement, CanvasElement,
CanvasElementData, CanvasElementData,
@ -12,9 +19,17 @@ import {
mapLegacyConnectionsToCanvasConnections, mapLegacyConnectionsToCanvasConnections,
mapLegacyEndpointsToCanvasConnectionPort, mapLegacyEndpointsToCanvasConnectionPort,
} from '@/utils/canvasUtilsV2'; } 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 { NodeHelpers } from 'n8n-workflow';
import type { IWorkflowDb } from '@/Interface'; import type { IWorkflowDb } from '@/Interface';
import { WAIT_TIME_UNLIMITED } from '@/constants';
import { sanitizeHtml } from '@/utils/htmlUtils';
export function useCanvasMapping({ export function useCanvasMapping({
workflow, workflow,
@ -23,7 +38,8 @@ export function useCanvasMapping({
workflow: Ref<IWorkflowDb>; workflow: Ref<IWorkflowDb>;
workflowObject: Ref<Workflow>; workflowObject: Ref<Workflow>;
}) { }) {
const locale = useI18n(); const i18n = useI18n();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const renderTypeByNodeType = computed( 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[]>(() => [ const elements = computed<CanvasElement[]>(() => [
...workflow.value.nodes.map<CanvasElement>((node) => { ...workflow.value.nodes.map<CanvasElement>((node) => {
const inputConnections = workflowObject.value.connectionsByDestinationNode[node.name] ?? {}; const inputConnections = workflowObject.value.connectionsByDestinationNode[node.name] ?? {};
@ -103,6 +210,22 @@ export function useCanvasMapping({
input: inputConnections, input: inputConnections,
output: outputConnections, 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', renderType: renderTypeByNodeType.value[node.type] ?? 'default',
}; };
@ -125,26 +248,63 @@ export function useCanvasMapping({
return mappedConnections.map((connection) => { return mappedConnections.map((connection) => {
const type = getConnectionType(connection); const type = getConnectionType(connection);
const label = getConnectionLabel(connection); const label = getConnectionLabel(connection);
const data = getConnectionData(connection);
return { return {
...connection, ...connection,
data,
type, type,
label, 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 { function getConnectionType(_: CanvasConnection): string {
return 'canvas-edge'; return 'canvas-edge';
} }
function getConnectionLabel(connection: CanvasConnection): string { 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) { if (!fromNode) {
return locale.baseText('ndv.output.items', { return '';
adjustToNumber: pinData.length, }
interpolate: { count: String(pinData.length) },
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 type { CanvasElement } from '@/types';
import { CanvasConnectionMode } from '@/types';
import type { import type {
AddedNodesAndConnections, AddedNodesAndConnections,
INodeUi, INodeUi,
@ -170,9 +176,9 @@ export function useCanvasOperations({
historyStore.startRecordingUndo(); historyStore.startRecordingUndo();
} }
workflowsStore.removeNodeById(id);
workflowsStore.removeNodeConnectionsById(id); workflowsStore.removeNodeConnectionsById(id);
workflowsStore.removeNodeExecutionDataById(id); workflowsStore.removeNodeExecutionDataById(id);
workflowsStore.removeNodeById(id);
if (trackHistory) { if (trackHistory) {
historyStore.pushCommandToUndo(new RemoveNodeCommand(node)); historyStore.pushCommandToUndo(new RemoveNodeCommand(node));
@ -215,7 +221,7 @@ export function useCanvasOperations({
return; return;
} }
ndvStore.activeNodeName = node.name; setNodeActiveByName(node.name);
} }
function setNodeActiveByName(name: string) { function setNodeActiveByName(name: string) {
@ -334,18 +340,32 @@ export function useCanvasOperations({
const outputIndex = lastSelectedNodeOutputIndex ?? 0; const outputIndex = lastSelectedNodeOutputIndex ?? 0;
const targetEndpoint = lastSelectedNodeEndpointUuid ?? ''; 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 (lastSelectedNode && !options.isAutoAdd) {
// If we have a specific endpoint to connect to
if (lastSelectedNodeEndpointUuid) { if (lastSelectedNodeEndpointUuid) {
const { type: connectionType } = parseCanvasConnectionHandleString( const { type: connectionType, mode } = parseCanvasConnectionHandleString(
lastSelectedNodeEndpointUuid, 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({ createConnection({
source: lastSelectedNode.id, source: newNodeId,
sourceHandle: targetEndpoint, sourceHandle: newNodeHandle,
target: newNodeData.id, target: lasSelectedNodeId,
targetHandle: `inputs/${connectionType}/0`, targetHandle: lastSelectedNodeHandle,
});
} else {
createConnection({
source: lasSelectedNodeId,
sourceHandle: lastSelectedNodeHandle,
target: newNodeId,
targetHandle: newNodeHandle,
}); });
} }
} else { } else {
@ -510,8 +530,6 @@ export function useCanvasOperations({
canvasStore.newNodeInsertPosition = null; canvasStore.newNodeInsertPosition = null;
} else { } else {
let yOffset = 0; let yOffset = 0;
const workflow = workflowsStore.getCurrentWorkflow();
if (lastSelectedConnection) { if (lastSelectedConnection) {
const sourceNodeType = nodeTypesStore.getNodeType( const sourceNodeType = nodeTypesStore.getNodeType(
lastSelectedNode.type, lastSelectedNode.type,
@ -526,7 +544,7 @@ export function useCanvasOperations({
]; ];
const sourceNodeOutputs = NodeHelpers.getNodeOutputs( const sourceNodeOutputs = NodeHelpers.getNodeOutputs(
workflow, editableWorkflowObject.value,
lastSelectedNode, lastSelectedNode,
sourceNodeType, sourceNodeType,
); );
@ -553,7 +571,11 @@ export function useCanvasOperations({
// outputs here is to calculate the position, it is fine to assume // 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 // that they have no outputs and are so treated as a regular node
// with only "main" outputs. // with only "main" outputs.
outputs = NodeHelpers.getNodeOutputs(workflow, newNodeData, nodeTypeDescription); outputs = NodeHelpers.getNodeOutputs(
editableWorkflowObject.value,
newNodeData,
nodeTypeDescription,
);
} catch (e) {} } catch (e) {}
const outputTypes = NodeHelpers.getConnectionTypes(outputs); const outputTypes = NodeHelpers.getConnectionTypes(outputs);
const lastSelectedNodeType = nodeTypesStore.getNodeType( const lastSelectedNodeType = nodeTypesStore.getNodeType(
@ -566,13 +588,15 @@ export function useCanvasOperations({
outputTypes.length > 0 && outputTypes.length > 0 &&
outputTypes.every((outputName) => outputName !== NodeConnectionType.Main) outputTypes.every((outputName) => outputName !== NodeConnectionType.Main)
) { ) {
const lastSelectedNodeWorkflow = workflow.getNode(lastSelectedNode.name); const lastSelectedNodeWorkflow = editableWorkflowObject.value.getNode(
lastSelectedNode.name,
);
if (!lastSelectedNodeWorkflow || !lastSelectedNodeType) { if (!lastSelectedNodeWorkflow || !lastSelectedNodeType) {
return; return;
} }
const lastSelectedInputs = NodeHelpers.getNodeInputs( const lastSelectedInputs = NodeHelpers.getNodeInputs(
workflow, editableWorkflowObject.value,
lastSelectedNodeWorkflow, lastSelectedNodeWorkflow,
lastSelectedNodeType, lastSelectedNodeType,
); );
@ -600,7 +624,7 @@ export function useCanvasOperations({
// Has only main outputs or no outputs at all // Has only main outputs or no outputs at all
const inputs = NodeHelpers.getNodeInputs( const inputs = NodeHelpers.getNodeInputs(
workflow, editableWorkflowObject.value,
lastSelectedNode, lastSelectedNode,
lastSelectedNodeType, lastSelectedNodeType,
); );
@ -683,10 +707,11 @@ export function useCanvasOperations({
{ trackHistory = false }: { trackHistory?: boolean }, { trackHistory = false }: { trackHistory?: boolean },
) { ) {
const sourceNode = workflowsStore.nodesByName[sourceNodeName]; const sourceNode = workflowsStore.nodesByName[sourceNodeName];
const checkNodes = workflowHelpers.getConnectedNodes(
const workflow = workflowHelpers.getCurrentWorkflow(); 'downstream',
editableWorkflowObject.value,
const checkNodes = workflowHelpers.getConnectedNodes('downstream', workflow, sourceNodeName); sourceNodeName,
);
for (const nodeName of checkNodes) { for (const nodeName of checkNodes) {
const node = workflowsStore.nodesByName[nodeName]; const node = workflowsStore.nodesByName[nodeName];
const oldPosition = node.position; const oldPosition = node.position;
@ -784,6 +809,9 @@ export function useCanvasOperations({
connection: mappedConnection, connection: mappedConnection,
}); });
nodeHelpers.updateNodeInputIssues(sourceNode);
nodeHelpers.updateNodeInputIssues(targetNode);
uiStore.stateIsDirty = true; uiStore.stateIsDirty = true;
} }
@ -829,46 +857,54 @@ export function useCanvasOperations({
function isConnectionAllowed( function isConnectionAllowed(
sourceNode: INodeUi, sourceNode: INodeUi,
targetNode: INodeUi, targetNode: INodeUi,
targetNodeConnectionType: NodeConnectionType, connectionType: NodeConnectionType,
): boolean { ): 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) { if (targetNodeType?.inputs?.length) {
const workflow = workflowsStore.getCurrentWorkflow(); const workflowNode = editableWorkflowObject.value.getNode(targetNode.name);
const workflowNode = workflow.getNode(targetNode.name);
if (!workflowNode) { if (!workflowNode) {
return false; return false;
} }
let inputs: Array<ConnectionTypes | INodeInputConfiguration> = []; let inputs: Array<ConnectionTypes | INodeInputConfiguration> = [];
if (targetNodeType) { if (targetNodeType) {
inputs = NodeHelpers.getNodeInputs(workflow, workflowNode, targetNodeType) || []; inputs =
NodeHelpers.getNodeInputs(editableWorkflowObject.value, workflowNode, targetNodeType) ||
[];
} }
let targetHasConnectionTypeAsInput = false;
for (const input of inputs) { for (const input of inputs) {
if (typeof input === 'string' || input.type !== targetNodeConnectionType || !input.filter) { const inputType = typeof input === 'string' ? input : input.type;
// No filters defined or wrong connection type if (inputType === connectionType) {
continue; 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) { return false;
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;
} }
targetHasConnectionTypeAsInput = true;
} }
} }
return targetHasConnectionTypeAsInput;
} }
return sourceNode.id !== targetNode.id; return false;
} }
async function addConnections( async function addConnections(
@ -907,5 +943,6 @@ export function useCanvasOperations({
createConnection, createConnection,
deleteConnection, deleteConnection,
revertDeleteConnection, 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() { function updateNodesInputIssues() {
const nodes = workflowsStore.allNodes; const nodes = workflowsStore.allNodes;
const workflow = workflowsStore.getCurrentWorkflow();
for (const node of nodes) { for (const node of nodes) {
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion); updateNodeInputIssues(node);
if (!nodeType) {
return;
}
const nodeInputIssues = getNodeInputIssues(workflow, node, nodeType);
workflowsStore.setNodeIssue({
node: node.name,
type: 'input',
value: nodeInputIssues?.input ? nodeInputIssues.input : null,
});
} }
} }
@ -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 { function updateNodeCredentialIssuesByName(name: string): void {
const node = workflowsStore.getNodeByName(name); const node = workflowsStore.getNodeByName(name);
@ -1228,6 +1241,8 @@ export function useNodeHelpers() {
getNodeIssues, getNodeIssues,
updateNodesInputIssues, updateNodesInputIssues,
updateNodesExecutionIssues, updateNodesExecutionIssues,
updateNodesParameterIssues,
updateNodeInputIssues,
updateNodeCredentialIssuesByName, updateNodeCredentialIssuesByName,
updateNodeCredentialIssues, updateNodeCredentialIssues,
updateNodeParameterIssuesByName, 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); workflowsStore.addWorkflow(workflowData);
if (set) {
workflowsStore.setWorkflow(workflowData);
}
workflowsStore.setActive(workflowData.active || false); workflowsStore.setActive(workflowData.active || false);
workflowsStore.setWorkflowId(workflowData.id); workflowsStore.setWorkflowId(workflowData.id);
workflowsStore.setWorkflowName({ workflowsStore.setWorkflowName({

View File

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

View File

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

View File

@ -31,7 +31,6 @@ import type {
WorkflowMetadata, WorkflowMetadata,
IExecutionFlattedResponse, IExecutionFlattedResponse,
IWorkflowTemplateNode, IWorkflowTemplateNode,
ITag,
} from '@/Interface'; } from '@/Interface';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import type { import type {
@ -74,7 +73,6 @@ import { i18n } from '@/plugins/i18n';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { useTagsStore } from '@/stores/tags.store';
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = { const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
name: '', name: '',
@ -1524,31 +1522,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
clearNodeExecutionData(node.name); 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 // End Canvas V2 Functions
// //
@ -1689,6 +1662,5 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
removeNodeExecutionDataById, removeNodeExecutionDataById,
setNodes, setNodes,
setConnections, setConnections,
initializeEditableWorkflow,
}; };
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,14 @@
<script setup lang="ts"> <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 { useRoute, useRouter } from 'vue-router';
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue'; import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@ -13,6 +22,7 @@ import type {
INodeUi, INodeUi,
IUpdateInformation, IUpdateInformation,
IWorkflowDataUpdate, IWorkflowDataUpdate,
IWorkflowDb,
ToggleNodeCreatorOptions, ToggleNodeCreatorOptions,
XYPosition, XYPosition,
} from '@/Interface'; } from '@/Interface';
@ -21,15 +31,19 @@ import type { CanvasElement } from '@/types';
import { import {
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT, CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT,
EnterpriseEditionFeature, EnterpriseEditionFeature,
MODAL_CANCEL,
MODAL_CONFIRM, MODAL_CONFIRM,
NEW_WORKFLOW_ID,
VIEWS, VIEWS,
} from '@/constants'; } from '@/constants';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useExternalHooks } from '@/composables/useExternalHooks'; 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 { useToast } from '@/composables/useToast';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
@ -48,6 +62,14 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useHistoryStore } from '@/stores/history.store'; import { useHistoryStore } from '@/stores/history.store';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { usePostHog } from '@/stores/posthog.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( const NodeCreation = defineAsyncComponent(
async () => await import('@/components/Node/NodeCreation.vue'), async () => await import('@/components/Node/NodeCreation.vue'),
@ -67,10 +89,13 @@ const toast = useToast();
const message = useMessage(); const message = useMessage();
const titleChange = useTitleChange(); const titleChange = useTitleChange();
const workflowHelpers = useWorkflowHelpers({ router }); const workflowHelpers = useWorkflowHelpers({ router });
const nodeHelpers = useNodeHelpers();
const posthog = usePostHog();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const workflowsEEStore = useWorkflowsEEStore();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const nodeCreatorStore = useNodeCreatorStore(); const nodeCreatorStore = useNodeCreatorStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
@ -83,6 +108,9 @@ const canvasStore = useCanvasStore();
const npsSurveyStore = useNpsSurveyStore(); const npsSurveyStore = useNpsSurveyStore();
const historyStore = useHistoryStore(); const historyStore = useHistoryStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const usersStore = useUsersStore();
const tagsStore = useTagsStore();
const pushConnectionStore = usePushConnectionStore();
const lastClickPosition = ref<XYPosition>([450, 450]); const lastClickPosition = ref<XYPosition>([450, 450]);
@ -105,6 +133,7 @@ const {
editableWorkflow, editableWorkflow,
editableWorkflowObject, editableWorkflowObject,
} = useCanvasOperations({ router, lastClickPosition }); } = useCanvasOperations({ router, lastClickPosition });
const { applyExecutionData } = useExecutionDebugging();
const isLoading = ref(true); const isLoading = ref(true);
const isBlankRedirect = ref(false); const isBlankRedirect = ref(false);
@ -120,6 +149,7 @@ const hideNodeIssues = ref(false);
const workflowId = computed<string>(() => route.params.workflowId as string); const workflowId = computed<string>(() => route.params.workflowId as string);
const workflow = computed(() => workflowsStore.workflowsById[workflowId.value]); const workflow = computed(() => workflowsStore.workflowsById[workflowId.value]);
const isNewWorkflowRoute = computed(() => route.name === VIEWS.NEW_WORKFLOW_V2);
const isDemoRoute = computed(() => route.name === VIEWS.DEMO); const isDemoRoute = computed(() => route.name === VIEWS.DEMO);
const isReadOnlyRoute = computed(() => route?.meta?.readOnlyCanvas === true); const isReadOnlyRoute = computed(() => route?.meta?.readOnlyCanvas === true);
const isReadOnlyEnvironment = computed(() => { const isReadOnlyEnvironment = computed(() => {
@ -132,58 +162,61 @@ const isReadOnlyEnvironment = computed(() => {
async function initializeData() { async function initializeData() {
isLoading.value = true; isLoading.value = true;
canvasStore.startLoading();
resetWorkspace(); resetWorkspace();
titleChange.titleReset(); titleChange.titleReset();
const loadPromises: Array<Promise<unknown>> = [ const loadPromises = (() => {
nodeTypesStore.getNodeTypes(), if (settingsStore.isPreviewMode && isDemoRoute.value) return [];
workflowsStore.fetchWorkflow(workflowId.value),
];
if (!settingsStore.isPreviewMode && !isDemoRoute.value) { const promises: Array<Promise<unknown>> = [
loadPromises.push(
workflowsStore.fetchActiveWorkflows(), workflowsStore.fetchActiveWorkflows(),
credentialsStore.fetchAllCredentials(), credentialsStore.fetchAllCredentials(),
credentialsStore.fetchCredentialTypes(true), credentialsStore.fetchCredentialTypes(true),
); ];
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Variables)) { if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Variables)) {
loadPromises.push(environmentsStore.fetchAllVariables()); promises.push(environmentsStore.fetchAllVariables());
} }
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.ExternalSecrets)) { if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.ExternalSecrets)) {
loadPromises.push(externalSecretsStore.fetchAllSecrets()); promises.push(externalSecretsStore.fetchAllSecrets());
} }
}
try { if (nodeTypesStore.allNodeTypes.length === 0) {
await Promise.all(loadPromises); promises.push(nodeTypesStore.getNodeTypes());
} catch (error) { }
return toast.showError(
error,
i18n.baseText('nodeView.showError.mounted1.title'),
i18n.baseText('nodeView.showError.mounted1.message') + ':',
);
}
void externalHooks.run('workflow.open', { return promises;
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;
}
// @TODO Implement this // @TODO Implement this
// this.clipboard.onPaste.value = this.onClipboardPasteEvent; // 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() { async function initializeView() {
@ -205,28 +238,6 @@ async function initializeView() {
// const templateId = route.params.id; // const templateId = route.params.id;
// await openWorkflowTemplate(templateId.toString()); // await openWorkflowTemplate(templateId.toString());
} else { } 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 // Get workflow id
let workflowIdParam: string | null = null; let workflowIdParam: string | null = null;
if (route.params.workflowId) { if (route.params.workflowId) {
@ -236,7 +247,7 @@ async function initializeView() {
historyStore.reset(); historyStore.reset();
// If there is no workflow id, treat it as a new workflow // 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) { if (route.meta?.nodeView === true) {
await initializeViewForNewWorkflow(); await initializeViewForNewWorkflow();
} }
@ -248,24 +259,25 @@ async function initializeView() {
await workflowsStore.fetchWorkflow(workflowIdParam); await workflowsStore.fetchWorkflow(workflowIdParam);
titleChange.titleSet(workflow.value.name, 'IDLE'); titleChange.titleSet(workflow.value.name, 'IDLE');
// @TODO Implement this await openWorkflow(workflow.value);
// await openWorkflow(workflow); await checkAndInitDebugMode();
// await checkAndInitDebugMode();
workflowsStore.initializeEditableWorkflow(workflowIdParam);
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflow.value.homeProject);
trackOpenWorkflowFromOnboardingTemplate(); trackOpenWorkflowFromOnboardingTemplate();
} catch (error) { } catch (error) {
toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError')); toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError'));
void router.push({ void router.push({
name: VIEWS.NEW_WORKFLOW, name: VIEWS.NEW_WORKFLOW_V2,
}); });
} }
} }
nodeHelpers.updateNodesInputIssues();
nodeHelpers.updateNodesCredentialsIssues();
nodeHelpers.updateNodesParameterIssues();
await loadCredentials(); await loadCredentials();
uiStore.nodeViewInitialized = true; uiStore.nodeViewInitialized = true;
// Once view is initialized, pick up all toast notifications // Once view is initialized, pick up all toast notifications
@ -284,53 +296,113 @@ async function initializeViewForNewWorkflow() {
uiStore.nodeViewInitialized = true; uiStore.nodeViewInitialized = true;
executionsStore.activeExecution = null; executionsStore.activeExecution = null;
// @TODO Implement this makeNewWorkflowShareable();
// canvasStore.setZoomLevel(1, [0, 0]); await runAutoAddManualTriggerExperiment();
// canvasStore.zoomToFit(); }
// @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
// 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(); async function runAutoAddManualTriggerExperiment() {
if ( 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 CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.variant
) { ) {
const manualTriggerNode = canvasStore.getAutoAddManualTriggerNode(); return;
if (manualTriggerNode) { }
await addNodes([manualTriggerNode]);
uiStore.lastSelectedNode = manualTriggerNode.name; 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() { function resetWorkspace() {
workflowsStore.resetWorkflow();
onToggleNodeCreator({ createNodeActive: false }); onToggleNodeCreator({ createNodeActive: false });
nodeCreatorStore.setShowScrim(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 // Make sure that if there is a waiting test-webhook that it gets removed
if (isExecutionWaitingForWebhook.value) { if (isExecutionWaitingForWebhook.value) {
try { try {
void workflowsStore.removeTestWebhook(workflowsStore.workflowId); void workflowsStore.removeTestWebhook(workflowsStore.workflowId);
} catch (error) {} } catch (error) {}
} }
workflowsStore.resetWorkflow();
workflowsStore.resetState(); workflowsStore.resetState();
uiStore.removeActiveAction('workflowRunning');
uiStore.removeActiveAction('workflowRunning');
uiStore.resetSelectedNodes(); uiStore.resetSelectedNodes();
uiStore.nodeViewOffsetPosition = [0, 0]; // @TODO Not sure if needed uiStore.nodeViewOffsetPosition = [0, 0]; // @TODO Not sure if needed
// this.credentialsUpdated = false; // 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() { function trackOpenWorkflowFromOnboardingTemplate() {
if (workflow.value.meta?.onboardingId) { if (workflow.value.meta?.onboardingId) {
telemetry.track( 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 * Nodes
*/ */
@ -479,27 +560,32 @@ function onToggleNodeCreator(options: ToggleNodeCreatorOptions) {
*/ */
async function onRunWorkflow() { async function onRunWorkflow() {
trackRunWorkflow();
await runWorkflow({}); 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) { async function openExecution(_executionId: string) {
// @TODO // @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 * Keboard
*/ */
@ -538,6 +624,38 @@ function removeUndoRedoEventBindings() {
// historyBus.off('enableNodeToggle', onRevertEnableToggle); // 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 * Post message events
*/ */
@ -647,6 +765,34 @@ function checkIfEditingIsAllowed(): boolean {
return true; 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 * Mouse events
*/ */
@ -656,25 +802,66 @@ function onClickPane(position: CanvasElement['position']) {
canvasStore.newNodeInsertPosition = [position.x, position.y]; 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 * Lifecycle
*/ */
onBeforeMount(() => {
if (!isDemoRoute.value) {
pushConnectionStore.pushConnect();
}
});
onMounted(async () => { onMounted(async () => {
await initializeData(); void initializeData().then(() => {
await initializeView(); void initializeView();
checkIfRouteIsAllowed();
});
addUndoRedoEventBindings(); addUndoRedoEventBindings();
addPostMessageEventBindings(); addPostMessageEventBindings();
addKeyboardEventBindings(); addKeyboardEventBindings();
addUnloadEventBindings(); addSourceControlEventBindings();
registerCustomActions();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
removeUnloadEventBindings();
removeKeyboardEventBindings(); removeKeyboardEventBindings();
removePostMessageEventBindings(); removePostMessageEventBindings();
removeUndoRedoEventBindings(); removeUndoRedoEventBindings();
removeSourceControlEventBindings();
}); });
</script> </script>

View File

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