mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08: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:
parent
1807835740
commit
8f970b5d37
|
@ -43,6 +43,7 @@
|
|||
"@jsplumb/util": "^5.13.2",
|
||||
"@lezer/common": "^1.0.4",
|
||||
"@n8n/chat": "workspace:*",
|
||||
"@n8n/codemirror-lang": "workspace:*",
|
||||
"@n8n/codemirror-lang-sql": "^1.0.2",
|
||||
"@n8n/permissions": "workspace:*",
|
||||
"@vue-flow/background": "^1.3.0",
|
||||
|
@ -55,7 +56,6 @@
|
|||
"axios": "1.6.7",
|
||||
"chart.js": "^4.4.0",
|
||||
"codemirror-lang-html-n8n": "^1.0.0",
|
||||
"@n8n/codemirror-lang": "workspace:*",
|
||||
"dateformat": "^3.0.3",
|
||||
"email-providers": "^2.0.1",
|
||||
"esprima-next": "5.8.4",
|
||||
|
|
|
@ -10,9 +10,17 @@ export function createCanvasNodeData({
|
|||
inputs = [],
|
||||
outputs = [],
|
||||
connections = { input: {}, output: {} },
|
||||
execution = {},
|
||||
issues = { items: [], visible: false },
|
||||
pinnedData = { count: 0, visible: false },
|
||||
runData = { count: 0, visible: false },
|
||||
renderType = 'default',
|
||||
}: Partial<CanvasElementData> = {}): CanvasElementData {
|
||||
return {
|
||||
execution,
|
||||
issues,
|
||||
pinnedData,
|
||||
runData,
|
||||
id,
|
||||
type,
|
||||
typeVersion,
|
||||
|
|
|
@ -9,6 +9,7 @@ import type {
|
|||
IWorkflowSettings,
|
||||
LoadedClass,
|
||||
INodeTypeDescription,
|
||||
INodeIssues,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeHelpers, Workflow } from 'n8n-workflow';
|
||||
import { uuid } from '@jsplumb/util';
|
||||
|
@ -23,6 +24,7 @@ import {
|
|||
NO_OP_NODE_TYPE,
|
||||
SET_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
|
||||
export const mockNode = ({
|
||||
id = uuid(),
|
||||
|
@ -30,22 +32,30 @@ export const mockNode = ({
|
|||
type,
|
||||
position = [0, 0],
|
||||
disabled = false,
|
||||
issues = undefined,
|
||||
typeVersion = 1,
|
||||
}: {
|
||||
id?: INode['id'];
|
||||
name: INode['name'];
|
||||
type: INode['type'];
|
||||
position?: INode['position'];
|
||||
disabled?: INode['disabled'];
|
||||
}) => mock<INode>({ id, name, type, position, disabled });
|
||||
id?: INodeUi['id'];
|
||||
name: INodeUi['name'];
|
||||
type: INodeUi['type'];
|
||||
position?: INodeUi['position'];
|
||||
disabled?: INodeUi['disabled'];
|
||||
issues?: INodeIssues;
|
||||
typeVersion?: INodeUi['typeVersion'];
|
||||
}) => mock<INodeUi>({ id, name, type, position, disabled, issues, typeVersion });
|
||||
|
||||
export const mockNodeTypeDescription = ({
|
||||
name,
|
||||
version = 1,
|
||||
credentials = [],
|
||||
inputs = ['main'],
|
||||
outputs = ['main'],
|
||||
}: {
|
||||
name: INodeTypeDescription['name'];
|
||||
version?: INodeTypeDescription['version'];
|
||||
credentials?: INodeTypeDescription['credentials'];
|
||||
inputs?: INodeTypeDescription['inputs'];
|
||||
outputs?: INodeTypeDescription['outputs'];
|
||||
}) =>
|
||||
mock<INodeTypeDescription>({
|
||||
name,
|
||||
|
@ -58,8 +68,8 @@ export const mockNodeTypeDescription = ({
|
|||
properties: [],
|
||||
maxNodes: Infinity,
|
||||
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
inputs,
|
||||
outputs,
|
||||
credentials,
|
||||
documentationUrl: 'https://docs',
|
||||
webhooks: undefined,
|
||||
|
|
|
@ -58,6 +58,10 @@ function onNodeDragStop(e: NodeDragEvent) {
|
|||
});
|
||||
}
|
||||
|
||||
function onSelectionDragStop(e: NodeDragEvent) {
|
||||
onNodeDragStop(e);
|
||||
}
|
||||
|
||||
function onSetNodeActive(id: string) {
|
||||
emit('update:node:active', id);
|
||||
}
|
||||
|
@ -121,6 +125,7 @@ function onClickPane(event: MouseEvent) {
|
|||
:max-zoom="2"
|
||||
data-test-id="canvas"
|
||||
@node-drag-stop="onNodeDragStop"
|
||||
@selection-drag-stop="onSelectionDragStop"
|
||||
@edge-mouse-enter="onMouseEnterEdge"
|
||||
@edge-mouse-leave="onMouseLeaveEdge"
|
||||
@pane-click="onClickPane"
|
||||
|
@ -156,8 +161,6 @@ function onClickPane(event: MouseEvent) {
|
|||
</VueFlow>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module></style>
|
||||
|
||||
<style lang="scss">
|
||||
.vue-flow__controls {
|
||||
display: flex;
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { fireEvent } from '@testing-library/vue';
|
||||
import CanvasEdge from './CanvasEdge.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasEdge, {
|
||||
props: {
|
||||
|
@ -10,9 +12,17 @@ const renderComponent = createComponentRenderer(CanvasEdge, {
|
|||
targetX: 100,
|
||||
targetY: 100,
|
||||
targetPosition: 'bottom',
|
||||
data: {
|
||||
status: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
describe('CanvasEdge', () => {
|
||||
it('should emit delete event when toolbar delete is clicked', async () => {
|
||||
const { emitted, getByTestId } = renderComponent();
|
||||
|
@ -24,19 +34,12 @@ describe('CanvasEdge', () => {
|
|||
});
|
||||
|
||||
it('should compute edgeStyle correctly', () => {
|
||||
const { container } = renderComponent({
|
||||
props: {
|
||||
style: {
|
||||
stroke: 'red',
|
||||
},
|
||||
},
|
||||
});
|
||||
const { container } = renderComponent();
|
||||
|
||||
const edge = container.querySelector('.vue-flow__edge-path');
|
||||
|
||||
expect(edge).toHaveStyle({
|
||||
stroke: 'red',
|
||||
strokeWidth: 2,
|
||||
stroke: 'var(--color-foreground-xdark)',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -17,12 +17,40 @@ const props = defineProps<
|
|||
|
||||
const $style = useCssModule();
|
||||
|
||||
const isFocused = computed(() => props.selected || props.hovered);
|
||||
|
||||
const status = computed(() => props.data.status);
|
||||
const statusColor = computed(() => {
|
||||
if (props.selected) {
|
||||
return 'var(--color-background-dark)';
|
||||
} else if (status.value === 'success') {
|
||||
return 'var(--color-success)';
|
||||
} else if (status.value === 'pinned') {
|
||||
return 'var(--color-secondary)';
|
||||
} else {
|
||||
return 'var(--color-foreground-xdark)';
|
||||
}
|
||||
});
|
||||
|
||||
const edgeStyle = computed(() => ({
|
||||
strokeWidth: 2,
|
||||
...props.style,
|
||||
strokeWidth: 2,
|
||||
stroke: statusColor.value,
|
||||
}));
|
||||
|
||||
const isEdgeToolbarVisible = computed(() => props.selected || props.hovered);
|
||||
const edgeLabel = computed(() => {
|
||||
if (isFocused.value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return props.label;
|
||||
});
|
||||
|
||||
const edgeLabelStyle = computed(() => ({
|
||||
fill: statusColor.value,
|
||||
transform: 'translateY(calc(var(--spacing-xs) * -1))',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
}));
|
||||
|
||||
const edgeToolbarStyle = computed(() => {
|
||||
return {
|
||||
|
@ -32,7 +60,7 @@ const edgeToolbarStyle = computed(() => {
|
|||
|
||||
const edgeToolbarClasses = computed(() => ({
|
||||
[$style.edgeToolbar]: true,
|
||||
[$style.edgeToolbarVisible]: isEdgeToolbarVisible.value,
|
||||
[$style.edgeToolbarVisible]: isFocused.value,
|
||||
nodrag: true,
|
||||
nopan: true,
|
||||
}));
|
||||
|
@ -63,18 +91,15 @@ function onDelete() {
|
|||
<template>
|
||||
<BaseEdge
|
||||
:id="id"
|
||||
:class="$style.edge"
|
||||
:style="edgeStyle"
|
||||
:path="path[0]"
|
||||
:marker-end="markerEnd"
|
||||
:label="data?.label"
|
||||
:label="edgeLabel"
|
||||
:label-x="path[1]"
|
||||
:label-y="path[2]"
|
||||
:label-style="{ fill: 'white' }"
|
||||
:label-show-bg="true"
|
||||
:label-bg-style="{ fill: 'red' }"
|
||||
:label-bg-padding="[2, 4]"
|
||||
:label-bg-border-radius="2"
|
||||
:class="$style.edge"
|
||||
:label-style="edgeLabelStyle"
|
||||
:label-show-bg="false"
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
<CanvasEdgeToolbar :class="edgeToolbarClasses" :style="edgeToolbarStyle" @delete="onDelete" />
|
||||
|
@ -82,6 +107,10 @@ function onDelete() {
|
|||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.edge {
|
||||
transition: stroke 0.3s ease;
|
||||
}
|
||||
|
||||
.edgeToolbar {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
|
|
|
@ -1,9 +1,16 @@
|
|||
import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createCanvasNodeProvide } from '@/__tests__/data';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasNodeRenderer);
|
||||
|
||||
beforeEach(() => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
describe('CanvasNodeRenderer', () => {
|
||||
it('should render default node correctly', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
|
|
|
@ -2,9 +2,16 @@ import CanvasNodeConfigurable from '@/components/canvas/elements/nodes/render-ty
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import { createCanvasNodeProvide } from '@/__tests__/data';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasNodeConfigurable);
|
||||
|
||||
beforeEach(() => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
describe('CanvasNodeConfigurable', () => {
|
||||
it('should render node correctly', () => {
|
||||
const { getByText } = renderComponent({
|
||||
|
|
|
@ -1,33 +1,40 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, inject, useCssModule } from 'vue';
|
||||
import { CanvasNodeKey, NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
|
||||
import { computed, useCssModule } from 'vue';
|
||||
import { NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
|
||||
import { useNodeConnections } from '@/composables/useNodeConnections';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import CanvasNodeDisabledStrikeThrough from './parts/CanvasNodeDisabledStrikeThrough.vue';
|
||||
|
||||
const node = inject(CanvasNodeKey);
|
||||
import CanvasNodeStatusIcons from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue';
|
||||
import { useCanvasNode } from '@/composables/useCanvasNode';
|
||||
|
||||
const $style = useCssModule();
|
||||
const i18n = useI18n();
|
||||
|
||||
const label = computed(() => node?.label.value ?? '');
|
||||
|
||||
const inputs = computed(() => node?.data.value.inputs ?? []);
|
||||
const outputs = computed(() => node?.data.value.outputs ?? []);
|
||||
const connections = computed(() => node?.data.value.connections ?? { input: {}, output: {} });
|
||||
const {
|
||||
label,
|
||||
inputs,
|
||||
outputs,
|
||||
connections,
|
||||
isDisabled,
|
||||
isSelected,
|
||||
hasPinnedData,
|
||||
hasRunData,
|
||||
hasIssues,
|
||||
} = useCanvasNode();
|
||||
const { nonMainInputs, requiredNonMainInputs } = useNodeConnections({
|
||||
inputs,
|
||||
outputs,
|
||||
connections,
|
||||
});
|
||||
|
||||
const isDisabled = computed(() => node?.data.value.disabled ?? false);
|
||||
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
[$style.node]: true,
|
||||
[$style.selected]: node?.selected.value,
|
||||
[$style.selected]: isSelected.value,
|
||||
[$style.disabled]: isDisabled.value,
|
||||
[$style.success]: hasRunData.value,
|
||||
[$style.error]: hasIssues.value,
|
||||
[$style.pinned]: hasPinnedData.value,
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -54,6 +61,7 @@ const styles = computed(() => {
|
|||
<template>
|
||||
<div :class="classes" :style="styles" data-test-id="canvas-node-configurable">
|
||||
<slot />
|
||||
<CanvasNodeStatusIcons :class="$style.statusIcons" />
|
||||
<CanvasNodeDisabledStrikeThrough v-if="isDisabled" />
|
||||
<div :class="$style.label">
|
||||
{{ label }}
|
||||
|
@ -80,6 +88,31 @@ const styles = computed(() => {
|
|||
background: var(--canvas-node--background, var(--color-node-background));
|
||||
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-xdark));
|
||||
border-radius: var(--border-radius-large);
|
||||
|
||||
&.selected {
|
||||
box-shadow: 0 0 0 4px var(--color-canvas-selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* State classes
|
||||
* The reverse order defines the priority in case multiple states are active
|
||||
*/
|
||||
|
||||
&.error {
|
||||
border-color: var(--color-canvas-node-error-border-color, var(--color-danger));
|
||||
}
|
||||
|
||||
&.success {
|
||||
border-color: var(--color-canvas-node-success-border-color, var(--color-success));
|
||||
}
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--color-canvas-node-pinned-border, var(--color-node-pinned-border));
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
|
@ -93,11 +126,9 @@ const styles = computed(() => {
|
|||
);
|
||||
}
|
||||
|
||||
.selected {
|
||||
box-shadow: 0 0 0 4px var(--color-canvas-selected);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
|
||||
.statusIcons {
|
||||
position: absolute;
|
||||
top: calc(var(--canvas-node--height) - 24px);
|
||||
right: var(--spacing-xs);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,9 +1,16 @@
|
|||
import CanvasNodeConfiguration from '@/components/canvas/elements/nodes/render-types/CanvasNodeConfiguration.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createCanvasNodeProvide } from '@/__tests__/data';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasNodeConfiguration);
|
||||
|
||||
beforeEach(() => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
describe('CanvasNodeConfiguration', () => {
|
||||
it('should render node correctly', () => {
|
||||
const { getByText } = renderComponent({
|
||||
|
|
|
@ -1,22 +1,20 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, inject, useCssModule } from 'vue';
|
||||
import { CanvasNodeKey } from '@/constants';
|
||||
import { computed, useCssModule } from 'vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const node = inject(CanvasNodeKey);
|
||||
import CanvasNodeStatusIcons from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue';
|
||||
import { useCanvasNode } from '@/composables/useCanvasNode';
|
||||
|
||||
const $style = useCssModule();
|
||||
const i18n = useI18n();
|
||||
|
||||
const label = computed(() => node?.label.value ?? '');
|
||||
|
||||
const isDisabled = computed(() => node?.data.value.disabled ?? false);
|
||||
const { label, isDisabled, isSelected, hasIssues } = useCanvasNode();
|
||||
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
[$style.node]: true,
|
||||
[$style.selected]: node?.selected.value,
|
||||
[$style.selected]: isSelected.value,
|
||||
[$style.disabled]: isDisabled.value,
|
||||
[$style.error]: hasIssues.value,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
@ -24,6 +22,7 @@ const classes = computed(() => {
|
|||
<template>
|
||||
<div :class="classes" data-test-id="canvas-node-configuration">
|
||||
<slot />
|
||||
<CanvasNodeStatusIcons :class="$style.statusIcons" />
|
||||
<div v-if="label" :class="$style.label">
|
||||
{{ label }}
|
||||
<div v-if="isDisabled">({{ i18n.baseText('node.disabled') }})</div>
|
||||
|
@ -44,6 +43,23 @@ const classes = computed(() => {
|
|||
background: var(--canvas-node--background, var(--node-type-supplemental-background));
|
||||
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-dark));
|
||||
border-radius: 50%;
|
||||
|
||||
/**
|
||||
* State classes
|
||||
* The reverse order defines the priority in case multiple states are active
|
||||
*/
|
||||
|
||||
&.selected {
|
||||
box-shadow: 0 0 0 4px var(--color-canvas-selected);
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--color-canvas-node-error-border-color, var(--color-danger));
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
|
@ -56,11 +72,8 @@ const classes = computed(() => {
|
|||
margin-top: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.selected {
|
||||
box-shadow: 0 0 0 4px var(--color-canvas-selected);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
|
||||
.statusIcons {
|
||||
position: absolute;
|
||||
top: calc(var(--canvas-node--height) - 24px);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -2,9 +2,16 @@ import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/C
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import { createCanvasNodeProvide } from '@/__tests__/data';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasNodeDefault);
|
||||
|
||||
beforeEach(() => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
describe('CanvasNodeDefault', () => {
|
||||
it('should render node correctly', () => {
|
||||
const { getByText } = renderComponent({
|
||||
|
|
|
@ -1,33 +1,39 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, inject, useCssModule } from 'vue';
|
||||
import { computed, useCssModule } from 'vue';
|
||||
import { useNodeConnections } from '@/composables/useNodeConnections';
|
||||
import { CanvasNodeKey } from '@/constants';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import CanvasNodeDisabledStrikeThrough from './parts/CanvasNodeDisabledStrikeThrough.vue';
|
||||
|
||||
const node = inject(CanvasNodeKey);
|
||||
import CanvasNodeStatusIcons from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue';
|
||||
import { useCanvasNode } from '@/composables/useCanvasNode';
|
||||
|
||||
const $style = useCssModule();
|
||||
const i18n = useI18n();
|
||||
|
||||
const label = computed(() => node?.label.value ?? '');
|
||||
|
||||
const inputs = computed(() => node?.data.value.inputs ?? []);
|
||||
const outputs = computed(() => node?.data.value.outputs ?? []);
|
||||
const connections = computed(() => node?.data.value.connections ?? { input: {}, output: {} });
|
||||
const {
|
||||
label,
|
||||
inputs,
|
||||
outputs,
|
||||
connections,
|
||||
isDisabled,
|
||||
isSelected,
|
||||
hasPinnedData,
|
||||
hasRunData,
|
||||
hasIssues,
|
||||
} = useCanvasNode();
|
||||
const { mainOutputs } = useNodeConnections({
|
||||
inputs,
|
||||
outputs,
|
||||
connections,
|
||||
});
|
||||
|
||||
const isDisabled = computed(() => node?.data.value.disabled ?? false);
|
||||
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
[$style.node]: true,
|
||||
[$style.selected]: node?.selected.value,
|
||||
[$style.selected]: isSelected.value,
|
||||
[$style.disabled]: isDisabled.value,
|
||||
[$style.success]: hasRunData.value,
|
||||
[$style.error]: hasIssues.value,
|
||||
[$style.pinned]: hasPinnedData.value,
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -39,8 +45,9 @@ const styles = computed(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="node" :class="classes" :style="styles" data-test-id="canvas-node-default">
|
||||
<div :class="classes" :style="styles" data-test-id="canvas-node-default">
|
||||
<slot />
|
||||
<CanvasNodeStatusIcons :class="$style.statusIcons" />
|
||||
<CanvasNodeDisabledStrikeThrough v-if="isDisabled" />
|
||||
<div v-if="label" :class="$style.label">
|
||||
{{ label }}
|
||||
|
@ -62,6 +69,31 @@ const styles = computed(() => {
|
|||
background: var(--canvas-node--background, var(--color-node-background));
|
||||
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-xdark));
|
||||
border-radius: var(--border-radius-large);
|
||||
|
||||
/**
|
||||
* State classes
|
||||
* The reverse order defines the priority in case multiple states are active
|
||||
*/
|
||||
|
||||
&.selected {
|
||||
box-shadow: 0 0 0 4px var(--color-canvas-selected);
|
||||
}
|
||||
|
||||
&.success {
|
||||
border-color: var(--color-canvas-node-success-border-color, var(--color-success));
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--color-canvas-node-error-border-color, var(--color-danger));
|
||||
}
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--color-canvas-node-pinned-border-color, var(--color-node-pinned-border));
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
border-color: var(--color-canvas-node-disabled-border-color, var(--color-foreground-base));
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
|
@ -74,11 +106,9 @@ const styles = computed(() => {
|
|||
margin-top: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.selected {
|
||||
box-shadow: 0 0 0 4px var(--color-canvas-selected);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
|
||||
.statusIcons {
|
||||
position: absolute;
|
||||
top: calc(var(--canvas-node--height) - 24px);
|
||||
right: var(--spacing-xs);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, inject, useCssModule } from 'vue';
|
||||
import { CanvasNodeKey } from '@/constants';
|
||||
import { computed, useCssModule } from 'vue';
|
||||
import { useNodeConnections } from '@/composables/useNodeConnections';
|
||||
import { useCanvasNode } from '@/composables/useCanvasNode';
|
||||
|
||||
const $style = useCssModule();
|
||||
const node = inject(CanvasNodeKey);
|
||||
|
||||
const inputs = computed(() => node?.data.value.inputs ?? []);
|
||||
const outputs = computed(() => node?.data.value.outputs ?? []);
|
||||
const connections = computed(() => node?.data.value.connections ?? { input: {}, output: {} });
|
||||
const { inputs, outputs, connections } = useCanvasNode();
|
||||
const { mainInputConnections, mainOutputConnections } = useNodeConnections({
|
||||
inputs,
|
||||
outputs,
|
||||
|
|
|
@ -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>
|
|
@ -21,6 +21,7 @@ import { useRouter } from 'vue-router';
|
|||
import { mock } from 'vitest-mock-extended';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
|
||||
vi.mock('vue-router', async () => {
|
||||
const actual = await import('vue-router');
|
||||
|
@ -39,11 +40,12 @@ describe('useCanvasOperations', () => {
|
|||
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
|
||||
let credentialsStore: ReturnType<typeof useCredentialsStore>;
|
||||
let canvasOperations: ReturnType<typeof useCanvasOperations>;
|
||||
let workflowHelpers: ReturnType<typeof useWorkflowHelpers>;
|
||||
|
||||
const lastClickPosition = ref<XYPosition>([450, 450]);
|
||||
const router = useRouter();
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
|
@ -53,15 +55,17 @@ describe('useCanvasOperations', () => {
|
|||
historyStore = useHistoryStore();
|
||||
nodeTypesStore = useNodeTypesStore();
|
||||
credentialsStore = useCredentialsStore();
|
||||
workflowHelpers = useWorkflowHelpers({ router });
|
||||
|
||||
const workflowId = 'test';
|
||||
workflowsStore.workflowsById[workflowId] = mock<IWorkflowDb>({
|
||||
const workflow = mock<IWorkflowDb>({
|
||||
id: workflowId,
|
||||
nodes: [],
|
||||
tags: [],
|
||||
usedCredentials: [],
|
||||
});
|
||||
workflowsStore.initializeEditableWorkflow(workflowId);
|
||||
workflowsStore.workflowsById[workflowId] = workflow;
|
||||
await workflowHelpers.initState(workflow, true);
|
||||
|
||||
canvasOperations = useCanvasOperations({ router, lastClickPosition });
|
||||
});
|
||||
|
@ -506,13 +510,13 @@ describe('useCanvasOperations', () => {
|
|||
connection: [
|
||||
{
|
||||
index: 0,
|
||||
node: 'Node B',
|
||||
type: 'main',
|
||||
node: 'Node A',
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
node: 'spy',
|
||||
type: 'main',
|
||||
node: 'Node B',
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
@ -567,6 +571,8 @@ describe('useCanvasOperations', () => {
|
|||
name: 'Node B',
|
||||
});
|
||||
|
||||
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB);
|
||||
|
||||
const connection: Connection = {
|
||||
source: nodeA.id,
|
||||
sourceHandle: `outputs/${NodeConnectionType.Main}/0`,
|
||||
|
@ -574,7 +580,14 @@ describe('useCanvasOperations', () => {
|
|||
targetHandle: `inputs/${NodeConnectionType.Main}/0`,
|
||||
};
|
||||
|
||||
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB);
|
||||
const nodeTypeDescription = mockNodeTypeDescription({
|
||||
name: 'node',
|
||||
inputs: [NodeConnectionType.Main],
|
||||
});
|
||||
|
||||
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
|
||||
canvasOperations.editableWorkflowObject.value.nodes[nodeA.name] = nodeA;
|
||||
canvasOperations.editableWorkflowObject.value.nodes[nodeB.name] = nodeB;
|
||||
|
||||
canvasOperations.createConnection(connection);
|
||||
|
||||
|
@ -588,6 +601,189 @@ describe('useCanvasOperations', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('isConnectionAllowed', () => {
|
||||
it('should return false if source and target nodes are the same', () => {
|
||||
const node = mockNode({ id: '1', type: 'testType', name: 'Test Node' });
|
||||
expect(canvasOperations.isConnectionAllowed(node, node, NodeConnectionType.Main)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if target node type does not have inputs', () => {
|
||||
const sourceNode = mockNode({
|
||||
id: '1',
|
||||
type: 'sourceType',
|
||||
name: 'Source Node',
|
||||
});
|
||||
const targetNode = mockNode({
|
||||
id: '2',
|
||||
type: 'targetType',
|
||||
name: 'Target Node',
|
||||
});
|
||||
const nodeTypeDescription = mockNodeTypeDescription({
|
||||
name: 'targetType',
|
||||
inputs: [],
|
||||
});
|
||||
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
|
||||
|
||||
expect(
|
||||
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if target node does not exist in the workflow', () => {
|
||||
const sourceNode = mockNode({
|
||||
id: '1',
|
||||
type: 'sourceType',
|
||||
name: 'Source Node',
|
||||
});
|
||||
const targetNode = mockNode({
|
||||
id: '2',
|
||||
type: 'targetType',
|
||||
name: 'Target Node',
|
||||
});
|
||||
const nodeTypeDescription = mockNodeTypeDescription({
|
||||
name: 'targetType',
|
||||
inputs: [NodeConnectionType.Main],
|
||||
});
|
||||
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
|
||||
|
||||
expect(
|
||||
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if input type does not match connection type', () => {
|
||||
const sourceNode = mockNode({
|
||||
id: '1',
|
||||
type: 'sourceType',
|
||||
name: 'Source Node',
|
||||
});
|
||||
|
||||
const targetNode = mockNode({
|
||||
id: '2',
|
||||
type: 'targetType',
|
||||
name: 'Target Node',
|
||||
});
|
||||
|
||||
const nodeTypeDescription = mockNodeTypeDescription({
|
||||
name: 'targetType',
|
||||
inputs: [NodeConnectionType.AiTool],
|
||||
});
|
||||
|
||||
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
|
||||
canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode;
|
||||
canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode;
|
||||
|
||||
expect(
|
||||
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if source node type is not allowed by target node input filter', () => {
|
||||
const sourceNode = mockNode({
|
||||
id: '1',
|
||||
type: 'sourceType',
|
||||
name: 'Source Node',
|
||||
typeVersion: 1,
|
||||
});
|
||||
|
||||
const targetNode = mockNode({
|
||||
id: '2',
|
||||
type: 'targetType',
|
||||
name: 'Target Node',
|
||||
typeVersion: 1,
|
||||
});
|
||||
|
||||
const nodeTypeDescription = mockNodeTypeDescription({
|
||||
name: 'targetType',
|
||||
inputs: [
|
||||
{
|
||||
type: NodeConnectionType.Main,
|
||||
filter: {
|
||||
nodes: ['allowedType'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
|
||||
canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode;
|
||||
canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode;
|
||||
|
||||
expect(
|
||||
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if all conditions including filter are met', () => {
|
||||
const sourceNode = mockNode({
|
||||
id: '1',
|
||||
type: 'sourceType',
|
||||
name: 'Source Node',
|
||||
typeVersion: 1,
|
||||
});
|
||||
|
||||
const targetNode = mockNode({
|
||||
id: '2',
|
||||
type: 'targetType',
|
||||
name: 'Target Node',
|
||||
typeVersion: 1,
|
||||
});
|
||||
|
||||
const nodeTypeDescription = mockNodeTypeDescription({
|
||||
name: 'targetType',
|
||||
inputs: [
|
||||
{
|
||||
type: NodeConnectionType.Main,
|
||||
filter: {
|
||||
nodes: ['sourceType'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
|
||||
canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode;
|
||||
canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode;
|
||||
|
||||
expect(
|
||||
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if all conditions are met and no filter is set', () => {
|
||||
const sourceNode = mockNode({
|
||||
id: '1',
|
||||
type: 'sourceType',
|
||||
name: 'Source Node',
|
||||
typeVersion: 1,
|
||||
});
|
||||
|
||||
const targetNode = mockNode({
|
||||
id: '2',
|
||||
type: 'targetType',
|
||||
name: 'Target Node',
|
||||
typeVersion: 1,
|
||||
});
|
||||
|
||||
const nodeTypeDescription = mockNodeTypeDescription({
|
||||
name: 'targetType',
|
||||
inputs: [
|
||||
{
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
|
||||
canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode;
|
||||
canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode;
|
||||
|
||||
expect(
|
||||
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteConnection', () => {
|
||||
it('should not delete a connection if source node does not exist', () => {
|
||||
const removeConnectionSpy = vi
|
||||
|
|
|
@ -7,24 +7,27 @@ import { mock } from 'vitest-mock-extended';
|
|||
|
||||
import { useCanvasMapping } from '@/composables/useCanvasMapping';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import { createTestWorkflowObject, mockNode, mockNodes } from '@/__tests__/mocks';
|
||||
import { MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
|
||||
|
||||
vi.mock('@/stores/nodeTypes.store', () => ({
|
||||
useNodeTypesStore: vi.fn(() => ({
|
||||
getNodeType: vi.fn(() => ({
|
||||
name: 'test',
|
||||
description: 'Test Node Description',
|
||||
})),
|
||||
isTriggerNode: vi.fn(),
|
||||
isConfigNode: vi.fn(),
|
||||
isConfigurableNode: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
import {
|
||||
createTestWorkflowObject,
|
||||
mockNode,
|
||||
mockNodes,
|
||||
mockNodeTypeDescription,
|
||||
} from '@/__tests__/mocks';
|
||||
import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
|
||||
beforeEach(() => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
useNodeTypesStore().setNodeTypes([
|
||||
mockNodeTypeDescription({
|
||||
name: MANUAL_TRIGGER_NODE_TYPE,
|
||||
}),
|
||||
mockNodeTypeDescription({
|
||||
name: SET_NODE_TYPE,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -75,13 +78,41 @@ describe('useCanvasMapping', () => {
|
|||
type: manualTriggerNode.type,
|
||||
typeVersion: expect.anything(),
|
||||
disabled: false,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
execution: {
|
||||
status: 'new',
|
||||
waiting: undefined,
|
||||
},
|
||||
issues: {
|
||||
items: [],
|
||||
visible: false,
|
||||
},
|
||||
pinnedData: {
|
||||
count: 0,
|
||||
visible: false,
|
||||
},
|
||||
runData: {
|
||||
count: 0,
|
||||
visible: false,
|
||||
},
|
||||
inputs: [
|
||||
{
|
||||
index: 0,
|
||||
label: undefined,
|
||||
type: 'main',
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
index: 0,
|
||||
label: undefined,
|
||||
type: 'main',
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
input: {},
|
||||
output: {},
|
||||
},
|
||||
renderType: 'default',
|
||||
renderType: 'trigger',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
@ -173,6 +204,7 @@ describe('useCanvasMapping', () => {
|
|||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
status: undefined,
|
||||
target: {
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
|
@ -219,6 +251,7 @@ describe('useCanvasMapping', () => {
|
|||
index: 0,
|
||||
type: NodeConnectionType.AiTool,
|
||||
},
|
||||
status: undefined,
|
||||
target: {
|
||||
index: 0,
|
||||
type: NodeConnectionType.AiTool,
|
||||
|
@ -239,6 +272,7 @@ describe('useCanvasMapping', () => {
|
|||
index: 0,
|
||||
type: NodeConnectionType.AiDocument,
|
||||
},
|
||||
status: undefined,
|
||||
target: {
|
||||
index: 1,
|
||||
type: NodeConnectionType.AiDocument,
|
|
@ -1,9 +1,16 @@
|
|||
/**
|
||||
* Canvas V2 Only
|
||||
* @TODO Remove this notice when Canvas V2 is the only one in use
|
||||
*/
|
||||
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import type { Ref } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import type {
|
||||
CanvasConnection,
|
||||
CanvasConnectionData,
|
||||
CanvasConnectionPort,
|
||||
CanvasElement,
|
||||
CanvasElementData,
|
||||
|
@ -12,9 +19,17 @@ import {
|
|||
mapLegacyConnectionsToCanvasConnections,
|
||||
mapLegacyEndpointsToCanvasConnectionPort,
|
||||
} from '@/utils/canvasUtilsV2';
|
||||
import type { Workflow } from 'n8n-workflow';
|
||||
import type {
|
||||
ExecutionStatus,
|
||||
ExecutionSummary,
|
||||
INodeExecutionData,
|
||||
ITaskData,
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeHelpers } from 'n8n-workflow';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import { WAIT_TIME_UNLIMITED } from '@/constants';
|
||||
import { sanitizeHtml } from '@/utils/htmlUtils';
|
||||
|
||||
export function useCanvasMapping({
|
||||
workflow,
|
||||
|
@ -23,7 +38,8 @@ export function useCanvasMapping({
|
|||
workflow: Ref<IWorkflowDb>;
|
||||
workflowObject: Ref<Workflow>;
|
||||
}) {
|
||||
const locale = useI18n();
|
||||
const i18n = useI18n();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
|
||||
const renderTypeByNodeType = computed(
|
||||
|
@ -87,6 +103,97 @@ export function useCanvasMapping({
|
|||
}, {}),
|
||||
);
|
||||
|
||||
const nodePinnedDataById = computed(() =>
|
||||
workflow.value.nodes.reduce<Record<string, INodeExecutionData[] | undefined>>((acc, node) => {
|
||||
acc[node.id] = workflowsStore.pinDataByNodeName(node.name);
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const nodeExecutionStatusById = computed(() =>
|
||||
workflow.value.nodes.reduce<Record<string, ExecutionStatus>>((acc, node) => {
|
||||
acc[node.id] =
|
||||
workflowsStore.getWorkflowRunData?.[node.name]?.filter(Boolean)[0].executionStatus ?? 'new';
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const nodeExecutionRunDataById = computed(() =>
|
||||
workflow.value.nodes.reduce<Record<string, ITaskData[] | null>>((acc, node) => {
|
||||
acc[node.id] = workflowsStore.getWorkflowResultDataByNodeName(node.name);
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const nodeIssuesById = computed(() =>
|
||||
workflow.value.nodes.reduce<Record<string, string[]>>((acc, node) => {
|
||||
const issues: string[] = [];
|
||||
const nodeExecutionRunData = workflowsStore.getWorkflowRunData?.[node.name];
|
||||
if (nodeExecutionRunData) {
|
||||
nodeExecutionRunData.forEach((executionRunData) => {
|
||||
if (executionRunData?.error) {
|
||||
const { message, description } = executionRunData.error;
|
||||
const issue = `${message}${description ? ` (${description})` : ''}`;
|
||||
issues.push(sanitizeHtml(issue));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (node?.issues !== undefined) {
|
||||
issues.push(...NodeHelpers.nodeIssuesToString(node.issues, node));
|
||||
}
|
||||
|
||||
acc[node.id] = issues;
|
||||
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const nodeHasIssuesById = computed(() =>
|
||||
workflow.value.nodes.reduce<Record<string, boolean>>((acc, node) => {
|
||||
if (['crashed', 'error'].includes(nodeExecutionStatusById.value[node.id])) {
|
||||
acc[node.id] = true;
|
||||
} else if (nodePinnedDataById.value[node.id]) {
|
||||
acc[node.id] = false;
|
||||
} else {
|
||||
acc[node.id] = Object.keys(node?.issues ?? {}).length > 0;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const nodeExecutionWaitingById = computed(() =>
|
||||
workflow.value.nodes.reduce<Record<string, string | undefined>>((acc, node) => {
|
||||
const isExecutionSummary = (execution: object): execution is ExecutionSummary =>
|
||||
'waitTill' in execution;
|
||||
|
||||
const workflowExecution = workflowsStore.getWorkflowExecution;
|
||||
const lastNodeExecuted = workflowExecution?.data?.resultData?.lastNodeExecuted;
|
||||
|
||||
if (workflowExecution && lastNodeExecuted && isExecutionSummary(workflowExecution)) {
|
||||
if (node.name === workflowExecution.data?.resultData?.lastNodeExecuted) {
|
||||
const waitDate = new Date(workflowExecution.waitTill as Date);
|
||||
|
||||
if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) {
|
||||
acc[node.id] = i18n.baseText(
|
||||
'node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall',
|
||||
);
|
||||
}
|
||||
|
||||
acc[node.id] = i18n.baseText('node.nodeIsWaitingTill', {
|
||||
interpolate: {
|
||||
date: waitDate.toLocaleDateString(),
|
||||
time: waitDate.toLocaleTimeString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const elements = computed<CanvasElement[]>(() => [
|
||||
...workflow.value.nodes.map<CanvasElement>((node) => {
|
||||
const inputConnections = workflowObject.value.connectionsByDestinationNode[node.name] ?? {};
|
||||
|
@ -103,6 +210,22 @@ export function useCanvasMapping({
|
|||
input: inputConnections,
|
||||
output: outputConnections,
|
||||
},
|
||||
issues: {
|
||||
items: nodeIssuesById.value[node.id],
|
||||
visible: nodeHasIssuesById.value[node.id],
|
||||
},
|
||||
pinnedData: {
|
||||
count: nodePinnedDataById.value[node.id]?.length ?? 0,
|
||||
visible: !!nodePinnedDataById.value[node.id],
|
||||
},
|
||||
execution: {
|
||||
status: nodeExecutionStatusById.value[node.id],
|
||||
waiting: nodeExecutionWaitingById.value[node.id],
|
||||
},
|
||||
runData: {
|
||||
count: nodeExecutionRunDataById.value[node.id]?.length ?? 0,
|
||||
visible: !!nodeExecutionRunDataById.value[node.id],
|
||||
},
|
||||
renderType: renderTypeByNodeType.value[node.type] ?? 'default',
|
||||
};
|
||||
|
||||
|
@ -125,26 +248,63 @@ export function useCanvasMapping({
|
|||
return mappedConnections.map((connection) => {
|
||||
const type = getConnectionType(connection);
|
||||
const label = getConnectionLabel(connection);
|
||||
const data = getConnectionData(connection);
|
||||
|
||||
return {
|
||||
...connection,
|
||||
data,
|
||||
type,
|
||||
label,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
function getConnectionData(connection: CanvasConnection): CanvasConnectionData {
|
||||
const fromNode = workflow.value.nodes.find(
|
||||
(node) => node.name === connection.data?.fromNodeName,
|
||||
);
|
||||
|
||||
let status: CanvasConnectionData['status'];
|
||||
if (fromNode) {
|
||||
if (nodePinnedDataById.value[fromNode.id] && nodeExecutionRunDataById.value[fromNode.id]) {
|
||||
status = 'pinned';
|
||||
} else if (nodeHasIssuesById.value[fromNode.id]) {
|
||||
status = 'error';
|
||||
} else if (nodeExecutionRunDataById.value[fromNode.id]) {
|
||||
status = 'success';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...(connection.data as CanvasConnectionData),
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function getConnectionType(_: CanvasConnection): string {
|
||||
return 'canvas-edge';
|
||||
}
|
||||
|
||||
function getConnectionLabel(connection: CanvasConnection): string {
|
||||
const pinData = workflow.value.pinData?.[connection.data?.fromNodeName ?? ''];
|
||||
const fromNode = workflow.value.nodes.find(
|
||||
(node) => node.name === connection.data?.fromNodeName,
|
||||
);
|
||||
|
||||
if (pinData?.length) {
|
||||
return locale.baseText('ndv.output.items', {
|
||||
adjustToNumber: pinData.length,
|
||||
interpolate: { count: String(pinData.length) },
|
||||
if (!fromNode) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (nodePinnedDataById.value[fromNode.id]) {
|
||||
const pinnedDataCount = nodePinnedDataById.value[fromNode.id]?.length ?? 0;
|
||||
return i18n.baseText('ndv.output.items', {
|
||||
adjustToNumber: pinnedDataCount,
|
||||
interpolate: { count: String(pinnedDataCount) },
|
||||
});
|
||||
} else if (nodeExecutionRunDataById.value[fromNode.id]) {
|
||||
const runDataCount = nodeExecutionRunDataById.value[fromNode.id]?.length ?? 0;
|
||||
return i18n.baseText('ndv.output.items', {
|
||||
adjustToNumber: runDataCount,
|
||||
interpolate: { count: String(runDataCount) },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
73
packages/editor-ui/src/composables/useCanvasNode.spec.ts
Normal file
73
packages/editor-ui/src/composables/useCanvasNode.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
69
packages/editor-ui/src/composables/useCanvasNode.ts
Normal file
69
packages/editor-ui/src/composables/useCanvasNode.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -1,4 +1,10 @@
|
|||
/**
|
||||
* Canvas V2 Only
|
||||
* @TODO Remove this notice when Canvas V2 is the only one in use
|
||||
*/
|
||||
|
||||
import type { CanvasElement } from '@/types';
|
||||
import { CanvasConnectionMode } from '@/types';
|
||||
import type {
|
||||
AddedNodesAndConnections,
|
||||
INodeUi,
|
||||
|
@ -170,9 +176,9 @@ export function useCanvasOperations({
|
|||
historyStore.startRecordingUndo();
|
||||
}
|
||||
|
||||
workflowsStore.removeNodeById(id);
|
||||
workflowsStore.removeNodeConnectionsById(id);
|
||||
workflowsStore.removeNodeExecutionDataById(id);
|
||||
workflowsStore.removeNodeById(id);
|
||||
|
||||
if (trackHistory) {
|
||||
historyStore.pushCommandToUndo(new RemoveNodeCommand(node));
|
||||
|
@ -215,7 +221,7 @@ export function useCanvasOperations({
|
|||
return;
|
||||
}
|
||||
|
||||
ndvStore.activeNodeName = node.name;
|
||||
setNodeActiveByName(node.name);
|
||||
}
|
||||
|
||||
function setNodeActiveByName(name: string) {
|
||||
|
@ -334,18 +340,32 @@ export function useCanvasOperations({
|
|||
const outputIndex = lastSelectedNodeOutputIndex ?? 0;
|
||||
const targetEndpoint = lastSelectedNodeEndpointUuid ?? '';
|
||||
|
||||
// Handle connection of scoped_endpoint types
|
||||
// Create a connection between the last selected node and the new one
|
||||
if (lastSelectedNode && !options.isAutoAdd) {
|
||||
// If we have a specific endpoint to connect to
|
||||
if (lastSelectedNodeEndpointUuid) {
|
||||
const { type: connectionType } = parseCanvasConnectionHandleString(
|
||||
const { type: connectionType, mode } = parseCanvasConnectionHandleString(
|
||||
lastSelectedNodeEndpointUuid,
|
||||
);
|
||||
if (isConnectionAllowed(lastSelectedNode, newNodeData, connectionType)) {
|
||||
|
||||
const newNodeId = newNodeData.id;
|
||||
const newNodeHandle = `${CanvasConnectionMode.Input}/${connectionType}/0`;
|
||||
const lasSelectedNodeId = lastSelectedNode.id;
|
||||
const lastSelectedNodeHandle = targetEndpoint;
|
||||
|
||||
if (mode === CanvasConnectionMode.Input) {
|
||||
createConnection({
|
||||
source: lastSelectedNode.id,
|
||||
sourceHandle: targetEndpoint,
|
||||
target: newNodeData.id,
|
||||
targetHandle: `inputs/${connectionType}/0`,
|
||||
source: newNodeId,
|
||||
sourceHandle: newNodeHandle,
|
||||
target: lasSelectedNodeId,
|
||||
targetHandle: lastSelectedNodeHandle,
|
||||
});
|
||||
} else {
|
||||
createConnection({
|
||||
source: lasSelectedNodeId,
|
||||
sourceHandle: lastSelectedNodeHandle,
|
||||
target: newNodeId,
|
||||
targetHandle: newNodeHandle,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
@ -510,8 +530,6 @@ export function useCanvasOperations({
|
|||
canvasStore.newNodeInsertPosition = null;
|
||||
} else {
|
||||
let yOffset = 0;
|
||||
const workflow = workflowsStore.getCurrentWorkflow();
|
||||
|
||||
if (lastSelectedConnection) {
|
||||
const sourceNodeType = nodeTypesStore.getNodeType(
|
||||
lastSelectedNode.type,
|
||||
|
@ -526,7 +544,7 @@ export function useCanvasOperations({
|
|||
];
|
||||
|
||||
const sourceNodeOutputs = NodeHelpers.getNodeOutputs(
|
||||
workflow,
|
||||
editableWorkflowObject.value,
|
||||
lastSelectedNode,
|
||||
sourceNodeType,
|
||||
);
|
||||
|
@ -553,7 +571,11 @@ export function useCanvasOperations({
|
|||
// outputs here is to calculate the position, it is fine to assume
|
||||
// that they have no outputs and are so treated as a regular node
|
||||
// with only "main" outputs.
|
||||
outputs = NodeHelpers.getNodeOutputs(workflow, newNodeData, nodeTypeDescription);
|
||||
outputs = NodeHelpers.getNodeOutputs(
|
||||
editableWorkflowObject.value,
|
||||
newNodeData,
|
||||
nodeTypeDescription,
|
||||
);
|
||||
} catch (e) {}
|
||||
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
|
||||
const lastSelectedNodeType = nodeTypesStore.getNodeType(
|
||||
|
@ -566,13 +588,15 @@ export function useCanvasOperations({
|
|||
outputTypes.length > 0 &&
|
||||
outputTypes.every((outputName) => outputName !== NodeConnectionType.Main)
|
||||
) {
|
||||
const lastSelectedNodeWorkflow = workflow.getNode(lastSelectedNode.name);
|
||||
const lastSelectedNodeWorkflow = editableWorkflowObject.value.getNode(
|
||||
lastSelectedNode.name,
|
||||
);
|
||||
if (!lastSelectedNodeWorkflow || !lastSelectedNodeType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastSelectedInputs = NodeHelpers.getNodeInputs(
|
||||
workflow,
|
||||
editableWorkflowObject.value,
|
||||
lastSelectedNodeWorkflow,
|
||||
lastSelectedNodeType,
|
||||
);
|
||||
|
@ -600,7 +624,7 @@ export function useCanvasOperations({
|
|||
|
||||
// Has only main outputs or no outputs at all
|
||||
const inputs = NodeHelpers.getNodeInputs(
|
||||
workflow,
|
||||
editableWorkflowObject.value,
|
||||
lastSelectedNode,
|
||||
lastSelectedNodeType,
|
||||
);
|
||||
|
@ -683,10 +707,11 @@ export function useCanvasOperations({
|
|||
{ trackHistory = false }: { trackHistory?: boolean },
|
||||
) {
|
||||
const sourceNode = workflowsStore.nodesByName[sourceNodeName];
|
||||
|
||||
const workflow = workflowHelpers.getCurrentWorkflow();
|
||||
|
||||
const checkNodes = workflowHelpers.getConnectedNodes('downstream', workflow, sourceNodeName);
|
||||
const checkNodes = workflowHelpers.getConnectedNodes(
|
||||
'downstream',
|
||||
editableWorkflowObject.value,
|
||||
sourceNodeName,
|
||||
);
|
||||
for (const nodeName of checkNodes) {
|
||||
const node = workflowsStore.nodesByName[nodeName];
|
||||
const oldPosition = node.position;
|
||||
|
@ -784,6 +809,9 @@ export function useCanvasOperations({
|
|||
connection: mappedConnection,
|
||||
});
|
||||
|
||||
nodeHelpers.updateNodeInputIssues(sourceNode);
|
||||
nodeHelpers.updateNodeInputIssues(targetNode);
|
||||
|
||||
uiStore.stateIsDirty = true;
|
||||
}
|
||||
|
||||
|
@ -829,46 +857,54 @@ export function useCanvasOperations({
|
|||
function isConnectionAllowed(
|
||||
sourceNode: INodeUi,
|
||||
targetNode: INodeUi,
|
||||
targetNodeConnectionType: NodeConnectionType,
|
||||
connectionType: NodeConnectionType,
|
||||
): boolean {
|
||||
const targetNodeType = nodeTypesStore.getNodeType(targetNode.type, targetNode.typeVersion);
|
||||
if (sourceNode.id === targetNode.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetNodeType = nodeTypesStore.getNodeType(targetNode.type, targetNode.typeVersion);
|
||||
if (targetNodeType?.inputs?.length) {
|
||||
const workflow = workflowsStore.getCurrentWorkflow();
|
||||
const workflowNode = workflow.getNode(targetNode.name);
|
||||
const workflowNode = editableWorkflowObject.value.getNode(targetNode.name);
|
||||
if (!workflowNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let inputs: Array<ConnectionTypes | INodeInputConfiguration> = [];
|
||||
if (targetNodeType) {
|
||||
inputs = NodeHelpers.getNodeInputs(workflow, workflowNode, targetNodeType) || [];
|
||||
inputs =
|
||||
NodeHelpers.getNodeInputs(editableWorkflowObject.value, workflowNode, targetNodeType) ||
|
||||
[];
|
||||
}
|
||||
|
||||
let targetHasConnectionTypeAsInput = false;
|
||||
for (const input of inputs) {
|
||||
if (typeof input === 'string' || input.type !== targetNodeConnectionType || !input.filter) {
|
||||
// No filters defined or wrong connection type
|
||||
continue;
|
||||
}
|
||||
const inputType = typeof input === 'string' ? input : input.type;
|
||||
if (inputType === connectionType) {
|
||||
if (typeof input === 'object' && 'filter' in input && input.filter?.nodes.length) {
|
||||
if (!input.filter.nodes.includes(sourceNode.type)) {
|
||||
// this.dropPrevented = true;
|
||||
toast.showToast({
|
||||
title: i18n.baseText('nodeView.showError.nodeNodeCompatible.title'),
|
||||
message: i18n.baseText('nodeView.showError.nodeNodeCompatible.message', {
|
||||
interpolate: { sourceNodeName: sourceNode.name, targetNodeName: targetNode.name },
|
||||
}),
|
||||
type: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
if (input.filter.nodes.length) {
|
||||
if (!input.filter.nodes.includes(sourceNode.type)) {
|
||||
// this.dropPrevented = true;
|
||||
toast.showToast({
|
||||
title: i18n.baseText('nodeView.showError.nodeNodeCompatible.title'),
|
||||
message: i18n.baseText('nodeView.showError.nodeNodeCompatible.message', {
|
||||
interpolate: { sourceNodeName: sourceNode.name, targetNodeName: targetNode.name },
|
||||
}),
|
||||
type: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
return false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
targetHasConnectionTypeAsInput = true;
|
||||
}
|
||||
}
|
||||
|
||||
return targetHasConnectionTypeAsInput;
|
||||
}
|
||||
|
||||
return sourceNode.id !== targetNode.id;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function addConnections(
|
||||
|
@ -907,5 +943,6 @@ export function useCanvasOperations({
|
|||
createConnection,
|
||||
deleteConnection,
|
||||
revertDeleteConnection,
|
||||
isConnectionAllowed,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -229,22 +229,27 @@ export function useNodeHelpers() {
|
|||
};
|
||||
}
|
||||
|
||||
function updateNodeInputIssues(node: INodeUi): void {
|
||||
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
||||
if (!nodeType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workflow = workflowsStore.getCurrentWorkflow();
|
||||
const nodeInputIssues = getNodeInputIssues(workflow, node, nodeType);
|
||||
|
||||
workflowsStore.setNodeIssue({
|
||||
node: node.name,
|
||||
type: 'input',
|
||||
value: nodeInputIssues?.input ? nodeInputIssues.input : null,
|
||||
});
|
||||
}
|
||||
|
||||
function updateNodesInputIssues() {
|
||||
const nodes = workflowsStore.allNodes;
|
||||
const workflow = workflowsStore.getCurrentWorkflow();
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
||||
if (!nodeType) {
|
||||
return;
|
||||
}
|
||||
const nodeInputIssues = getNodeInputIssues(workflow, node, nodeType);
|
||||
|
||||
workflowsStore.setNodeIssue({
|
||||
node: node.name,
|
||||
type: 'input',
|
||||
value: nodeInputIssues?.input ? nodeInputIssues.input : null,
|
||||
});
|
||||
updateNodeInputIssues(node);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -260,6 +265,14 @@ export function useNodeHelpers() {
|
|||
}
|
||||
}
|
||||
|
||||
function updateNodesParameterIssues() {
|
||||
const nodes = workflowsStore.allNodes;
|
||||
|
||||
for (const node of nodes) {
|
||||
updateNodeParameterIssues(node);
|
||||
}
|
||||
}
|
||||
|
||||
function updateNodeCredentialIssuesByName(name: string): void {
|
||||
const node = workflowsStore.getNodeByName(name);
|
||||
|
||||
|
@ -1228,6 +1241,8 @@ export function useNodeHelpers() {
|
|||
getNodeIssues,
|
||||
updateNodesInputIssues,
|
||||
updateNodesExecutionIssues,
|
||||
updateNodesParameterIssues,
|
||||
updateNodeInputIssues,
|
||||
updateNodeCredentialIssuesByName,
|
||||
updateNodeCredentialIssues,
|
||||
updateNodeParameterIssuesByName,
|
||||
|
|
|
@ -1050,8 +1050,12 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
|
|||
}
|
||||
}
|
||||
|
||||
async function initState(workflowData: IWorkflowDb): Promise<void> {
|
||||
async function initState(workflowData: IWorkflowDb, set = false): Promise<void> {
|
||||
workflowsStore.addWorkflow(workflowData);
|
||||
if (set) {
|
||||
workflowsStore.setWorkflow(workflowData);
|
||||
}
|
||||
|
||||
workflowsStore.setActive(workflowData.active || false);
|
||||
workflowsStore.setWorkflowId(workflowData.id);
|
||||
workflowsStore.setWorkflowName({
|
||||
|
|
|
@ -451,6 +451,7 @@ export const enum VIEWS {
|
|||
CREDENTIALS = 'CredentialsView',
|
||||
VARIABLES = 'VariablesView',
|
||||
NEW_WORKFLOW = 'NodeViewNew',
|
||||
NEW_WORKFLOW_V2 = 'NodeViewNewV2',
|
||||
WORKFLOW = 'NodeViewExisting',
|
||||
WORKFLOW_V2 = 'NodeViewV2',
|
||||
DEMO = 'WorkflowDemo',
|
||||
|
@ -489,8 +490,9 @@ export const enum VIEWS {
|
|||
export const EDITABLE_CANVAS_VIEWS = [
|
||||
VIEWS.WORKFLOW,
|
||||
VIEWS.NEW_WORKFLOW,
|
||||
VIEWS.EXECUTION_DEBUG,
|
||||
VIEWS.WORKFLOW_V2,
|
||||
VIEWS.NEW_WORKFLOW_V2,
|
||||
VIEWS.EXECUTION_DEBUG,
|
||||
];
|
||||
|
||||
export const enum FAKE_DOOR_FEATURES {
|
||||
|
|
|
@ -70,6 +70,10 @@ function getTemplatesRedirect(defaultRedirect: VIEWS[keyof VIEWS]): { name: stri
|
|||
return false;
|
||||
}
|
||||
|
||||
function nodeViewV2CustomMiddleware() {
|
||||
return !!localStorage.getItem('features.NodeViewV2');
|
||||
}
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
|
@ -367,13 +371,29 @@ export const routes: RouteRecordRaw[] = [
|
|||
sidebar: MainSidebar,
|
||||
},
|
||||
meta: {
|
||||
nodeView: true,
|
||||
keepWorkflowAlive: true,
|
||||
middleware: ['authenticated', 'custom'],
|
||||
middlewareOptions: {
|
||||
custom: () => {
|
||||
return !!localStorage.getItem('features.NodeViewV2');
|
||||
},
|
||||
custom: nodeViewV2CustomMiddleware,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/workflow-v2/new',
|
||||
name: VIEWS.NEW_WORKFLOW_V2,
|
||||
components: {
|
||||
default: NodeViewV2,
|
||||
header: MainHeader,
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
meta: {
|
||||
nodeView: true,
|
||||
keepWorkflowAlive: true,
|
||||
middleware: ['authenticated', 'custom'],
|
||||
middlewareOptions: {
|
||||
custom: nodeViewV2CustomMiddleware,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -31,7 +31,6 @@ import type {
|
|||
WorkflowMetadata,
|
||||
IExecutionFlattedResponse,
|
||||
IWorkflowTemplateNode,
|
||||
ITag,
|
||||
} from '@/Interface';
|
||||
import { defineStore } from 'pinia';
|
||||
import type {
|
||||
|
@ -74,7 +73,6 @@ import { i18n } from '@/plugins/i18n';
|
|||
|
||||
import { computed, ref } from 'vue';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
|
||||
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
||||
name: '',
|
||||
|
@ -1524,31 +1522,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||
clearNodeExecutionData(node.name);
|
||||
}
|
||||
|
||||
function initializeEditableWorkflow(id: string) {
|
||||
const targetWorkflow = workflowsById.value[id];
|
||||
const tags = (targetWorkflow?.tags ?? []) as ITag[];
|
||||
const tagIds = tags.map((tag) => tag.id);
|
||||
|
||||
addWorkflow(targetWorkflow);
|
||||
setWorkflow(targetWorkflow);
|
||||
setActive(targetWorkflow.active || false);
|
||||
setWorkflowId(targetWorkflow.id);
|
||||
setWorkflowName({ newName: targetWorkflow.name, setStateDirty: false });
|
||||
setWorkflowSettings(targetWorkflow.settings ?? {});
|
||||
setWorkflowPinData(targetWorkflow.pinData ?? {});
|
||||
setWorkflowVersionId(targetWorkflow.versionId);
|
||||
setWorkflowMetadata(targetWorkflow.meta);
|
||||
if (targetWorkflow.usedCredentials) {
|
||||
setUsedCredentials(targetWorkflow.usedCredentials);
|
||||
}
|
||||
setWorkflowTagIds(tagIds || []);
|
||||
|
||||
if (tags.length > 0) {
|
||||
const tagsStore = useTagsStore();
|
||||
tagsStore.upsertTags(tags);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// End Canvas V2 Functions
|
||||
//
|
||||
|
@ -1689,6 +1662,5 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||
removeNodeExecutionDataById,
|
||||
setNodes,
|
||||
setConnections,
|
||||
initializeEditableWorkflow,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -6,13 +6,13 @@
|
|||
content: 'n8n supports all JavaScript functions, including those not listed.';
|
||||
}
|
||||
|
||||
.code-node-editor .ͼ2 .cm-tooltip-autocomplete > ul[role='listbox'] {
|
||||
.code-node-editor .cm-editor .cm-tooltip-autocomplete > ul[role='listbox'] {
|
||||
border-bottom: none;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.ͼ2 .cm-tooltip-autocomplete {
|
||||
.cm-editor .cm-tooltip-autocomplete {
|
||||
background-color: var(--color-background-xlight) !important;
|
||||
box-shadow: var(--box-shadow-light);
|
||||
border: none;
|
||||
|
@ -94,9 +94,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
.ͼ2 .cm-tooltip.cm-completionInfo,
|
||||
.ͼ2 .cm-tooltip.cm-cursorInfo,
|
||||
.ͼ2 .cm-tooltip-hover {
|
||||
.cm-editor .cm-tooltip.cm-completionInfo,
|
||||
.cm-editor .cm-tooltip.cm-cursorInfo,
|
||||
.cm-editor .cm-tooltip-hover {
|
||||
// Add padding when infobox only contains text
|
||||
&:not(:has(div)) {
|
||||
padding: var(--spacing-xs);
|
||||
|
@ -264,7 +264,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.ͼ2 .cm-tooltip.cm-completionInfo {
|
||||
.cm-editor .cm-tooltip.cm-completionInfo {
|
||||
background-color: var(--color-background-xlight);
|
||||
border: var(--border-base);
|
||||
box-shadow: var(--box-shadow-light);
|
||||
|
@ -305,8 +305,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
.ͼ2 .cm-tooltip.cm-cursorInfo,
|
||||
.ͼ2 .cm-tooltip-hover {
|
||||
.cm-editor .cm-tooltip.cm-cursorInfo,
|
||||
.cm-editor .cm-tooltip-hover {
|
||||
background-color: var(--color-infobox-background);
|
||||
border: var(--border-base);
|
||||
box-shadow: var(--box-shadow-light);
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
|
||||
import type { ConnectionTypes, INodeConnections, INodeTypeDescription } from 'n8n-workflow';
|
||||
import type {
|
||||
ConnectionTypes,
|
||||
ExecutionStatus,
|
||||
INodeConnections,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
|
||||
import type { DefaultEdge, Node, NodeProps, Position } from '@vue-flow/core';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
|
@ -9,6 +14,16 @@ export type CanvasElementType = 'node' | 'note';
|
|||
|
||||
export type CanvasConnectionPortType = ConnectionTypes;
|
||||
|
||||
export const enum CanvasConnectionMode {
|
||||
Input = 'inputs',
|
||||
Output = 'outputs',
|
||||
}
|
||||
|
||||
export const canvasConnectionModes = [
|
||||
CanvasConnectionMode.Input,
|
||||
CanvasConnectionMode.Output,
|
||||
] as const;
|
||||
|
||||
export type CanvasConnectionPort = {
|
||||
type: CanvasConnectionPortType;
|
||||
required?: boolean;
|
||||
|
@ -32,6 +47,22 @@ export interface CanvasElementData {
|
|||
input: INodeConnections;
|
||||
output: INodeConnections;
|
||||
};
|
||||
issues: {
|
||||
items: string[];
|
||||
visible: boolean;
|
||||
};
|
||||
pinnedData: {
|
||||
count: number;
|
||||
visible: boolean;
|
||||
};
|
||||
execution: {
|
||||
status?: ExecutionStatus;
|
||||
waiting?: string;
|
||||
};
|
||||
runData: {
|
||||
count: number;
|
||||
visible: boolean;
|
||||
};
|
||||
renderType: 'default' | 'trigger' | 'configuration' | 'configurable';
|
||||
}
|
||||
|
||||
|
@ -41,6 +72,7 @@ export interface CanvasConnectionData {
|
|||
source: CanvasConnectionPort;
|
||||
target: CanvasConnectionPort;
|
||||
fromNodeName?: string;
|
||||
status?: 'success' | 'error' | 'pinned';
|
||||
}
|
||||
|
||||
export type CanvasConnection = DefaultEdge<CanvasConnectionData>;
|
||||
|
|
|
@ -428,10 +428,11 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
|
|||
|
||||
describe('parseCanvasConnectionHandleString', () => {
|
||||
it('should parse valid handle string', () => {
|
||||
const handle = 'outputs/main/1';
|
||||
const handle = 'inputs/main/1';
|
||||
const result = parseCanvasConnectionHandleString(handle);
|
||||
|
||||
expect(result).toEqual({
|
||||
mode: 'inputs',
|
||||
type: 'main',
|
||||
index: 1,
|
||||
});
|
||||
|
@ -442,6 +443,7 @@ describe('parseCanvasConnectionHandleString', () => {
|
|||
const result = parseCanvasConnectionHandleString(handle);
|
||||
|
||||
expect(result).toEqual({
|
||||
mode: 'outputs',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
});
|
||||
|
@ -452,6 +454,7 @@ describe('parseCanvasConnectionHandleString', () => {
|
|||
const result = parseCanvasConnectionHandleString(handle);
|
||||
|
||||
expect(result).toEqual({
|
||||
mode: 'outputs',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
});
|
||||
|
@ -462,6 +465,7 @@ describe('parseCanvasConnectionHandleString', () => {
|
|||
const result = parseCanvasConnectionHandleString(handle);
|
||||
|
||||
expect(result).toEqual({
|
||||
mode: 'outputs',
|
||||
type: 'main',
|
||||
index: 1,
|
||||
});
|
||||
|
@ -472,6 +476,7 @@ describe('parseCanvasConnectionHandleString', () => {
|
|||
const result = parseCanvasConnectionHandleString(handle);
|
||||
|
||||
expect(result).toEqual({
|
||||
mode: 'outputs',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
});
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import type { IConnection, IConnections, INodeTypeDescription } from 'n8n-workflow';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import type { CanvasConnection, CanvasConnectionPortType, CanvasConnectionPort } from '@/types';
|
||||
import { CanvasConnectionMode } from '@/types';
|
||||
import type { Connection } from '@vue-flow/core';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||
import { isValidCanvasConnectionMode, isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
export function mapLegacyConnectionsToCanvasConnections(
|
||||
|
@ -29,8 +30,8 @@ export function mapLegacyConnectionsToCanvasConnections(
|
|||
id: `[${fromId}/${fromConnectionType}/${fromIndex}][${toId}/${toConnectionType}/${toIndex}]`,
|
||||
source: fromId,
|
||||
target: toId,
|
||||
sourceHandle: `outputs/${fromConnectionType}/${fromIndex}`,
|
||||
targetHandle: `inputs/${toConnectionType}/${toIndex}`,
|
||||
sourceHandle: `${CanvasConnectionMode.Output}/${fromConnectionType}/${fromIndex}`,
|
||||
targetHandle: `${CanvasConnectionMode.Input}/${toConnectionType}/${toIndex}`,
|
||||
data: {
|
||||
fromNodeName,
|
||||
source: {
|
||||
|
@ -53,9 +54,10 @@ export function mapLegacyConnectionsToCanvasConnections(
|
|||
}
|
||||
|
||||
export function parseCanvasConnectionHandleString(handle: string | null | undefined) {
|
||||
const [, type, index] = (handle ?? '').split('/');
|
||||
const [mode, type, index] = (handle ?? '').split('/');
|
||||
|
||||
const resolvedType = isValidNodeConnectionType(type) ? type : NodeConnectionType.Main;
|
||||
const resolvedMode = isValidCanvasConnectionMode(mode) ? mode : CanvasConnectionMode.Output;
|
||||
|
||||
let resolvedIndex = parseInt(index, 10);
|
||||
if (isNaN(resolvedIndex)) {
|
||||
|
@ -63,6 +65,7 @@ export function parseCanvasConnectionHandleString(handle: string | null | undefi
|
|||
}
|
||||
|
||||
return {
|
||||
mode: resolvedMode,
|
||||
type: resolvedType,
|
||||
index: resolvedIndex,
|
||||
};
|
||||
|
|
|
@ -9,6 +9,8 @@ import type { IExecutionResponse, ICredentialsResponse, NewCredentialsModal } fr
|
|||
import type { jsPlumbDOMElement } from '@jsplumb/browser-ui';
|
||||
import type { Connection } from '@jsplumb/core';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
import type { CanvasConnectionMode } from '@/types';
|
||||
import { canvasConnectionModes } from '@/types';
|
||||
|
||||
/*
|
||||
Type guards used in editor-ui project
|
||||
|
@ -69,6 +71,10 @@ export function isValidNodeConnectionType(
|
|||
return nodeConnectionTypes.includes(connectionType as NodeConnectionType);
|
||||
}
|
||||
|
||||
export function isValidCanvasConnectionMode(mode: string): mode is CanvasConnectionMode {
|
||||
return canvasConnectionModes.includes(mode as CanvasConnectionMode);
|
||||
}
|
||||
|
||||
export function isTriggerPanelObject(
|
||||
triggerPanel: INodeTypeDescription['triggerPanel'],
|
||||
): triggerPanel is TriggerPanelDefinition {
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, useCssModule } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
nextTick,
|
||||
onBeforeMount,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
useCssModule,
|
||||
} from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
|
@ -13,6 +22,7 @@ import type {
|
|||
INodeUi,
|
||||
IUpdateInformation,
|
||||
IWorkflowDataUpdate,
|
||||
IWorkflowDb,
|
||||
ToggleNodeCreatorOptions,
|
||||
XYPosition,
|
||||
} from '@/Interface';
|
||||
|
@ -21,15 +31,19 @@ import type { CanvasElement } from '@/types';
|
|||
import {
|
||||
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT,
|
||||
EnterpriseEditionFeature,
|
||||
MODAL_CANCEL,
|
||||
MODAL_CONFIRM,
|
||||
NEW_WORKFLOW_ID,
|
||||
VIEWS,
|
||||
} from '@/constants';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import type { NodeConnectionType, ExecutionSummary, IConnection } from 'n8n-workflow';
|
||||
import { TelemetryHelpers } from 'n8n-workflow';
|
||||
import type {
|
||||
NodeConnectionType,
|
||||
ExecutionSummary,
|
||||
IConnection,
|
||||
IWorkflowBase,
|
||||
} from 'n8n-workflow';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
|
@ -48,6 +62,14 @@ import { useTelemetry } from '@/composables/useTelemetry';
|
|||
import { useHistoryStore } from '@/stores/history.store';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import useWorkflowsEEStore from '@/stores/workflows.ee.store';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
|
||||
import type { ProjectSharingData } from '@/types/projects.types';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { sourceControlEventBus } from '@/event-bus/source-control';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||
|
||||
const NodeCreation = defineAsyncComponent(
|
||||
async () => await import('@/components/Node/NodeCreation.vue'),
|
||||
|
@ -67,10 +89,13 @@ const toast = useToast();
|
|||
const message = useMessage();
|
||||
const titleChange = useTitleChange();
|
||||
const workflowHelpers = useWorkflowHelpers({ router });
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const posthog = usePostHog();
|
||||
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const uiStore = useUIStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const workflowsEEStore = useWorkflowsEEStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
@ -83,6 +108,9 @@ const canvasStore = useCanvasStore();
|
|||
const npsSurveyStore = useNpsSurveyStore();
|
||||
const historyStore = useHistoryStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const usersStore = useUsersStore();
|
||||
const tagsStore = useTagsStore();
|
||||
const pushConnectionStore = usePushConnectionStore();
|
||||
|
||||
const lastClickPosition = ref<XYPosition>([450, 450]);
|
||||
|
||||
|
@ -105,6 +133,7 @@ const {
|
|||
editableWorkflow,
|
||||
editableWorkflowObject,
|
||||
} = useCanvasOperations({ router, lastClickPosition });
|
||||
const { applyExecutionData } = useExecutionDebugging();
|
||||
|
||||
const isLoading = ref(true);
|
||||
const isBlankRedirect = ref(false);
|
||||
|
@ -120,6 +149,7 @@ const hideNodeIssues = ref(false);
|
|||
const workflowId = computed<string>(() => route.params.workflowId as string);
|
||||
const workflow = computed(() => workflowsStore.workflowsById[workflowId.value]);
|
||||
|
||||
const isNewWorkflowRoute = computed(() => route.name === VIEWS.NEW_WORKFLOW_V2);
|
||||
const isDemoRoute = computed(() => route.name === VIEWS.DEMO);
|
||||
const isReadOnlyRoute = computed(() => route?.meta?.readOnlyCanvas === true);
|
||||
const isReadOnlyEnvironment = computed(() => {
|
||||
|
@ -132,58 +162,61 @@ const isReadOnlyEnvironment = computed(() => {
|
|||
|
||||
async function initializeData() {
|
||||
isLoading.value = true;
|
||||
canvasStore.startLoading();
|
||||
|
||||
resetWorkspace();
|
||||
titleChange.titleReset();
|
||||
|
||||
const loadPromises: Array<Promise<unknown>> = [
|
||||
nodeTypesStore.getNodeTypes(),
|
||||
workflowsStore.fetchWorkflow(workflowId.value),
|
||||
];
|
||||
const loadPromises = (() => {
|
||||
if (settingsStore.isPreviewMode && isDemoRoute.value) return [];
|
||||
|
||||
if (!settingsStore.isPreviewMode && !isDemoRoute.value) {
|
||||
loadPromises.push(
|
||||
const promises: Array<Promise<unknown>> = [
|
||||
workflowsStore.fetchActiveWorkflows(),
|
||||
credentialsStore.fetchAllCredentials(),
|
||||
credentialsStore.fetchCredentialTypes(true),
|
||||
);
|
||||
];
|
||||
|
||||
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Variables)) {
|
||||
loadPromises.push(environmentsStore.fetchAllVariables());
|
||||
promises.push(environmentsStore.fetchAllVariables());
|
||||
}
|
||||
|
||||
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.ExternalSecrets)) {
|
||||
loadPromises.push(externalSecretsStore.fetchAllSecrets());
|
||||
promises.push(externalSecretsStore.fetchAllSecrets());
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(loadPromises);
|
||||
} catch (error) {
|
||||
return toast.showError(
|
||||
error,
|
||||
i18n.baseText('nodeView.showError.mounted1.title'),
|
||||
i18n.baseText('nodeView.showError.mounted1.message') + ':',
|
||||
);
|
||||
}
|
||||
if (nodeTypesStore.allNodeTypes.length === 0) {
|
||||
promises.push(nodeTypesStore.getNodeTypes());
|
||||
}
|
||||
|
||||
void externalHooks.run('workflow.open', {
|
||||
workflowId: workflowsStore.workflow.id,
|
||||
workflowName: workflowsStore.workflow.name,
|
||||
});
|
||||
|
||||
const selectedExecution = executionsStore.activeExecution;
|
||||
if (selectedExecution?.workflowId !== workflowsStore.workflow.id) {
|
||||
executionsStore.activeExecution = null;
|
||||
workflowsStore.currentWorkflowExecutions = [];
|
||||
} else {
|
||||
executionsStore.activeExecution = selectedExecution;
|
||||
}
|
||||
return promises;
|
||||
})();
|
||||
|
||||
// @TODO Implement this
|
||||
// this.clipboard.onPaste.value = this.onClipboardPasteEvent;
|
||||
|
||||
isLoading.value = false;
|
||||
try {
|
||||
await Promise.all(loadPromises);
|
||||
} catch (error) {
|
||||
toast.showError(
|
||||
error,
|
||||
i18n.baseText('nodeView.showError.mounted1.title'),
|
||||
i18n.baseText('nodeView.showError.mounted1.message') + ':',
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
canvasStore.stopLoading();
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
void usersStore.showPersonalizationSurvey();
|
||||
}, 0);
|
||||
|
||||
// @TODO: This currently breaks since front-end hooks are still not updated to work with pinia store
|
||||
void externalHooks.run('nodeView.mount').catch(() => {});
|
||||
|
||||
// @TODO maybe we can find a better way to handle this
|
||||
canvasStore.isDemo = isDemoRoute.value;
|
||||
}
|
||||
|
||||
async function initializeView() {
|
||||
|
@ -205,28 +238,6 @@ async function initializeView() {
|
|||
// const templateId = route.params.id;
|
||||
// await openWorkflowTemplate(templateId.toString());
|
||||
} else {
|
||||
if (uiStore.stateIsDirty && !isReadOnlyEnvironment.value) {
|
||||
const confirmModal = await message.confirm(
|
||||
i18n.baseText('generic.unsavedWork.confirmMessage.message'),
|
||||
{
|
||||
title: i18n.baseText('generic.unsavedWork.confirmMessage.headline'),
|
||||
type: 'warning',
|
||||
confirmButtonText: i18n.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
|
||||
cancelButtonText: i18n.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
|
||||
showClose: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmModal === MODAL_CONFIRM) {
|
||||
const saved = await workflowHelpers.saveCurrentWorkflow();
|
||||
if (saved) {
|
||||
await npsSurveyStore.fetchPromptsData();
|
||||
}
|
||||
} else if (confirmModal === MODAL_CANCEL) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get workflow id
|
||||
let workflowIdParam: string | null = null;
|
||||
if (route.params.workflowId) {
|
||||
|
@ -236,7 +247,7 @@ async function initializeView() {
|
|||
historyStore.reset();
|
||||
|
||||
// If there is no workflow id, treat it as a new workflow
|
||||
if (!workflowIdParam || workflowIdParam === NEW_WORKFLOW_ID) {
|
||||
if (!workflowIdParam || isNewWorkflowRoute.value) {
|
||||
if (route.meta?.nodeView === true) {
|
||||
await initializeViewForNewWorkflow();
|
||||
}
|
||||
|
@ -248,24 +259,25 @@ async function initializeView() {
|
|||
await workflowsStore.fetchWorkflow(workflowIdParam);
|
||||
|
||||
titleChange.titleSet(workflow.value.name, 'IDLE');
|
||||
// @TODO Implement this
|
||||
// await openWorkflow(workflow);
|
||||
// await checkAndInitDebugMode();
|
||||
|
||||
workflowsStore.initializeEditableWorkflow(workflowIdParam);
|
||||
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflow.value.homeProject);
|
||||
await openWorkflow(workflow.value);
|
||||
await checkAndInitDebugMode();
|
||||
|
||||
trackOpenWorkflowFromOnboardingTemplate();
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError'));
|
||||
|
||||
void router.push({
|
||||
name: VIEWS.NEW_WORKFLOW,
|
||||
name: VIEWS.NEW_WORKFLOW_V2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
nodeHelpers.updateNodesInputIssues();
|
||||
nodeHelpers.updateNodesCredentialsIssues();
|
||||
nodeHelpers.updateNodesParameterIssues();
|
||||
|
||||
await loadCredentials();
|
||||
|
||||
uiStore.nodeViewInitialized = true;
|
||||
|
||||
// Once view is initialized, pick up all toast notifications
|
||||
|
@ -284,53 +296,113 @@ async function initializeViewForNewWorkflow() {
|
|||
uiStore.nodeViewInitialized = true;
|
||||
executionsStore.activeExecution = null;
|
||||
|
||||
// @TODO Implement this
|
||||
// canvasStore.setZoomLevel(1, [0, 0]);
|
||||
// canvasStore.zoomToFit();
|
||||
makeNewWorkflowShareable();
|
||||
await runAutoAddManualTriggerExperiment();
|
||||
}
|
||||
|
||||
// @TODO Implement this
|
||||
// this.makeNewWorkflowShareable();
|
||||
|
||||
// Pre-populate the canvas with the manual trigger node if the experiment is enabled and the user is in the variant group
|
||||
const { getVariant } = usePostHog();
|
||||
/**
|
||||
* Pre-populate the canvas with the manual trigger node
|
||||
* if the experiment is enabled and the user is in the variant group
|
||||
*/
|
||||
async function runAutoAddManualTriggerExperiment() {
|
||||
if (
|
||||
getVariant(CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.name) ===
|
||||
posthog.getVariant(CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.name) !==
|
||||
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.variant
|
||||
) {
|
||||
const manualTriggerNode = canvasStore.getAutoAddManualTriggerNode();
|
||||
if (manualTriggerNode) {
|
||||
await addNodes([manualTriggerNode]);
|
||||
uiStore.lastSelectedNode = manualTriggerNode.name;
|
||||
return;
|
||||
}
|
||||
|
||||
const manualTriggerNode = canvasStore.getAutoAddManualTriggerNode();
|
||||
if (manualTriggerNode) {
|
||||
await addNodes([manualTriggerNode]);
|
||||
uiStore.lastSelectedNode = manualTriggerNode.name;
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error @TODO Add binding on route leave
|
||||
async function promptSaveOnBeforeRouteLeave() {
|
||||
if (uiStore.stateIsDirty && !isReadOnlyEnvironment.value) {
|
||||
const confirmModal = await message.confirm(
|
||||
i18n.baseText('generic.unsavedWork.confirmMessage.message'),
|
||||
{
|
||||
title: i18n.baseText('generic.unsavedWork.confirmMessage.headline'),
|
||||
type: 'warning',
|
||||
confirmButtonText: i18n.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
|
||||
cancelButtonText: i18n.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
|
||||
showClose: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmModal === MODAL_CONFIRM) {
|
||||
const saved = await workflowHelpers.saveCurrentWorkflow();
|
||||
if (saved) {
|
||||
await npsSurveyStore.fetchPromptsData();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetWorkspace() {
|
||||
workflowsStore.resetWorkflow();
|
||||
|
||||
onToggleNodeCreator({ createNodeActive: false });
|
||||
nodeCreatorStore.setShowScrim(false);
|
||||
|
||||
// @TODO Implement this
|
||||
// Reset nodes
|
||||
// this.unbindEndpointEventListeners();
|
||||
// this.deleteEveryEndpoint();
|
||||
|
||||
// Make sure that if there is a waiting test-webhook that it gets removed
|
||||
if (isExecutionWaitingForWebhook.value) {
|
||||
try {
|
||||
void workflowsStore.removeTestWebhook(workflowsStore.workflowId);
|
||||
} catch (error) {}
|
||||
}
|
||||
workflowsStore.resetWorkflow();
|
||||
workflowsStore.resetState();
|
||||
uiStore.removeActiveAction('workflowRunning');
|
||||
|
||||
uiStore.removeActiveAction('workflowRunning');
|
||||
uiStore.resetSelectedNodes();
|
||||
uiStore.nodeViewOffsetPosition = [0, 0]; // @TODO Not sure if needed
|
||||
|
||||
// this.credentialsUpdated = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow
|
||||
*/
|
||||
|
||||
async function openWorkflow(data: IWorkflowDb) {
|
||||
const selectedExecution = executionsStore.activeExecution;
|
||||
|
||||
resetWorkspace();
|
||||
|
||||
await workflowHelpers.initState(data, true);
|
||||
|
||||
if (data.sharedWithProjects) {
|
||||
workflowsEEStore.setWorkflowSharedWith({
|
||||
workflowId: data.id,
|
||||
sharedWithProjects: data.sharedWithProjects,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.usedCredentials) {
|
||||
workflowsStore.setUsedCredentials(data.usedCredentials);
|
||||
}
|
||||
|
||||
if (!nodeHelpers.credentialsUpdated.value) {
|
||||
uiStore.stateIsDirty = false;
|
||||
}
|
||||
|
||||
void externalHooks.run('workflow.open', {
|
||||
workflowId: data.id,
|
||||
workflowName: data.name,
|
||||
});
|
||||
|
||||
if (selectedExecution?.workflowId !== data.id) {
|
||||
executionsStore.activeExecution = null;
|
||||
workflowsStore.currentWorkflowExecutions = [];
|
||||
} else {
|
||||
executionsStore.activeExecution = selectedExecution;
|
||||
}
|
||||
|
||||
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflow.value.homeProject);
|
||||
}
|
||||
|
||||
function trackOpenWorkflowFromOnboardingTemplate() {
|
||||
if (workflow.value.meta?.onboardingId) {
|
||||
telemetry.track(
|
||||
|
@ -345,6 +417,15 @@ function trackOpenWorkflowFromOnboardingTemplate() {
|
|||
}
|
||||
}
|
||||
|
||||
function makeNewWorkflowShareable() {
|
||||
const { currentProject, personalProject } = projectsStore;
|
||||
const homeProject = currentProject ?? personalProject ?? {};
|
||||
const scopes = currentProject?.scopes ?? personalProject?.scopes ?? [];
|
||||
|
||||
workflowsStore.workflow.homeProject = homeProject as ProjectSharingData;
|
||||
workflowsStore.workflow.scopes = scopes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nodes
|
||||
*/
|
||||
|
@ -479,27 +560,32 @@ function onToggleNodeCreator(options: ToggleNodeCreatorOptions) {
|
|||
*/
|
||||
|
||||
async function onRunWorkflow() {
|
||||
trackRunWorkflow();
|
||||
|
||||
await runWorkflow({});
|
||||
}
|
||||
|
||||
function trackRunWorkflow() {
|
||||
void workflowHelpers.getWorkflowDataToSave().then((workflowData) => {
|
||||
const telemetryPayload = {
|
||||
workflow_id: workflowId.value,
|
||||
node_graph_string: JSON.stringify(
|
||||
TelemetryHelpers.generateNodesGraph(
|
||||
workflowData as IWorkflowBase,
|
||||
workflowHelpers.getNodeTypes(),
|
||||
{ isCloudDeployment: settingsStore.isCloudDeployment },
|
||||
).nodeGraph,
|
||||
),
|
||||
};
|
||||
telemetry.track('User clicked execute workflow button', telemetryPayload);
|
||||
void externalHooks.run('nodeView.onRunWorkflow', telemetryPayload);
|
||||
});
|
||||
}
|
||||
|
||||
async function openExecution(_executionId: string) {
|
||||
// @TODO
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload
|
||||
*/
|
||||
|
||||
function addUnloadEventBindings() {
|
||||
// window.addEventListener('beforeunload', this.onBeforeUnload);
|
||||
// window.addEventListener('unload', this.onUnload);
|
||||
}
|
||||
|
||||
function removeUnloadEventBindings() {
|
||||
// window.removeEventListener('beforeunload', this.onBeforeUnload);
|
||||
// window.removeEventListener('unload', this.onUnload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keboard
|
||||
*/
|
||||
|
@ -538,6 +624,38 @@ function removeUndoRedoEventBindings() {
|
|||
// historyBus.off('enableNodeToggle', onRevertEnableToggle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Source control
|
||||
*/
|
||||
|
||||
async function onSourceControlPull() {
|
||||
try {
|
||||
await Promise.all([
|
||||
environmentsStore.fetchAllVariables(),
|
||||
tagsStore.fetchAll(),
|
||||
loadCredentials(),
|
||||
]);
|
||||
|
||||
if (workflowId.value !== null && !uiStore.stateIsDirty) {
|
||||
const workflowData = await workflowsStore.fetchWorkflow(workflowId.value);
|
||||
if (workflowData) {
|
||||
titleChange.titleSet(workflowData.name, 'IDLE');
|
||||
await openWorkflow(workflowData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function addSourceControlEventBindings() {
|
||||
sourceControlEventBus.on('pull', onSourceControlPull);
|
||||
}
|
||||
|
||||
function removeSourceControlEventBindings() {
|
||||
sourceControlEventBus.off('pull', onSourceControlPull);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post message events
|
||||
*/
|
||||
|
@ -647,6 +765,34 @@ function checkIfEditingIsAllowed(): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
function checkIfRouteIsAllowed() {
|
||||
if (
|
||||
isReadOnlyEnvironment.value &&
|
||||
[VIEWS.NEW_WORKFLOW, VIEWS.TEMPLATE_IMPORT].find((view) => view === route.name)
|
||||
) {
|
||||
void nextTick(async () => {
|
||||
resetWorkspace();
|
||||
uiStore.stateIsDirty = false;
|
||||
|
||||
await router.replace({ name: VIEWS.HOMEPAGE });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug mode
|
||||
*/
|
||||
|
||||
async function checkAndInitDebugMode() {
|
||||
if (route.name === VIEWS.EXECUTION_DEBUG) {
|
||||
titleChange.titleSet(workflowsStore.workflowName, 'DEBUG');
|
||||
if (!workflowsStore.isInDebugMode) {
|
||||
await applyExecutionData(route.params.executionId as string);
|
||||
workflowsStore.isInDebugMode = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse events
|
||||
*/
|
||||
|
@ -656,25 +802,66 @@ function onClickPane(position: CanvasElement['position']) {
|
|||
canvasStore.newNodeInsertPosition = [position.x, position.y];
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom Actions
|
||||
*/
|
||||
|
||||
function registerCustomActions() {
|
||||
// @TODO Implement these
|
||||
// this.registerCustomAction({
|
||||
// key: 'openNodeDetail',
|
||||
// action: ({ node }: { node: string }) => {
|
||||
// this.nodeSelectedByName(node, true);
|
||||
// },
|
||||
// });
|
||||
//
|
||||
// this.registerCustomAction({
|
||||
// key: 'openSelectiveNodeCreator',
|
||||
// action: this.openSelectiveNodeCreator,
|
||||
// });
|
||||
//
|
||||
// this.registerCustomAction({
|
||||
// key: 'showNodeCreator',
|
||||
// action: () => {
|
||||
// this.ndvStore.activeNodeName = null;
|
||||
//
|
||||
// void this.$nextTick(() => {
|
||||
// this.showTriggerCreator(NODE_CREATOR_OPEN_SOURCES.TAB);
|
||||
// });
|
||||
// },
|
||||
// });
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle
|
||||
*/
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (!isDemoRoute.value) {
|
||||
pushConnectionStore.pushConnect();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await initializeData();
|
||||
await initializeView();
|
||||
void initializeData().then(() => {
|
||||
void initializeView();
|
||||
|
||||
checkIfRouteIsAllowed();
|
||||
});
|
||||
|
||||
addUndoRedoEventBindings();
|
||||
addPostMessageEventBindings();
|
||||
addKeyboardEventBindings();
|
||||
addUnloadEventBindings();
|
||||
addSourceControlEventBindings();
|
||||
|
||||
registerCustomActions();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
removeUnloadEventBindings();
|
||||
removeKeyboardEventBindings();
|
||||
removePostMessageEventBindings();
|
||||
removeUndoRedoEventBindings();
|
||||
removeSourceControlEventBindings();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1185,7 +1185,7 @@ importers:
|
|||
version: 10.5.0(vue@3.4.21(typescript@5.5.2))
|
||||
axios:
|
||||
specifier: 1.6.7
|
||||
version: 1.6.7(debug@3.2.7)
|
||||
version: 1.6.7
|
||||
chart.js:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
|
@ -16118,7 +16118,7 @@ snapshots:
|
|||
'@antfu/install-pkg': 0.1.1
|
||||
'@antfu/utils': 0.7.6
|
||||
'@iconify/types': 2.0.0
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
kolorist: 1.8.0
|
||||
local-pkg: 0.4.3
|
||||
transitivePeerDependencies:
|
||||
|
@ -19962,7 +19962,7 @@ snapshots:
|
|||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
@ -20266,6 +20266,14 @@ snapshots:
|
|||
'@babel/runtime': 7.23.6
|
||||
is-retry-allowed: 2.2.0
|
||||
|
||||
axios@1.6.7:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.6
|
||||
form-data: 4.0.0
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
axios@1.6.7(debug@3.2.7):
|
||||
dependencies:
|
||||
follow-redirects: 1.15.6(debug@3.2.7)
|
||||
|
@ -21292,6 +21300,10 @@ snapshots:
|
|||
optionalDependencies:
|
||||
supports-color: 8.1.1
|
||||
|
||||
debug@4.3.4:
|
||||
dependencies:
|
||||
ms: 2.1.2
|
||||
|
||||
debug@4.3.4(supports-color@8.1.1):
|
||||
dependencies:
|
||||
ms: 2.1.2
|
||||
|
@ -22466,6 +22478,8 @@ snapshots:
|
|||
|
||||
fn.name@1.1.0: {}
|
||||
|
||||
follow-redirects@1.15.6: {}
|
||||
|
||||
follow-redirects@1.15.6(debug@3.2.7):
|
||||
optionalDependencies:
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
|
@ -23078,7 +23092,7 @@ snapshots:
|
|||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
@ -27814,7 +27828,7 @@ snapshots:
|
|||
'@antfu/install-pkg': 0.1.1
|
||||
'@antfu/utils': 0.7.6
|
||||
'@iconify/utils': 2.1.11
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
kolorist: 1.8.0
|
||||
local-pkg: 0.5.0
|
||||
unplugin: 1.5.1
|
||||
|
|
Loading…
Reference in a new issue