fix(editor): Provide correct node output runData information in new canvas (no-changelog) (#10691)

This commit is contained in:
Alex Grozav 2024-09-06 13:37:44 +03:00 committed by GitHub
parent 156eb72ebe
commit 468f01aaa8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 421 additions and 50 deletions

View file

@ -6,6 +6,7 @@ import type {
CanvasNodeEventBusEvents, CanvasNodeEventBusEvents,
CanvasNodeHandleInjectionData, CanvasNodeHandleInjectionData,
CanvasNodeInjectionData, CanvasNodeInjectionData,
ExecutionOutputMapData,
} from '@/types'; } from '@/types';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types'; import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
@ -25,7 +26,7 @@ export function createCanvasNodeData({
execution = { running: false }, execution = { running: false },
issues = { items: [], visible: false }, issues = { items: [], visible: false },
pinnedData = { count: 0, visible: false }, pinnedData = { count: 0, visible: false },
runData = { count: 0, visible: false }, runData = { outputMap: {}, iterations: 0, visible: false },
render = { render = {
type: CanvasNodeRenderType.Default, type: CanvasNodeRenderType.Default,
options: { configurable: false, configuration: false, trigger: false }, options: { configurable: false, configuration: false, trigger: false },
@ -119,6 +120,8 @@ export function createCanvasHandleProvide({
label = 'Handle', label = 'Handle',
mode = CanvasConnectionMode.Input, mode = CanvasConnectionMode.Input,
type = NodeConnectionType.Main, type = NodeConnectionType.Main,
index = 0,
runData,
isConnected = false, isConnected = false,
isConnecting = false, isConnecting = false,
isReadOnly = false, isReadOnly = false,
@ -126,6 +129,8 @@ export function createCanvasHandleProvide({
label?: string; label?: string;
mode?: CanvasConnectionMode; mode?: CanvasConnectionMode;
type?: NodeConnectionType; type?: NodeConnectionType;
index?: number;
runData?: ExecutionOutputMapData;
isConnected?: boolean; isConnected?: boolean;
isConnecting?: boolean; isConnecting?: boolean;
isReadOnly?: boolean; isReadOnly?: boolean;
@ -135,8 +140,10 @@ export function createCanvasHandleProvide({
label: ref(label), label: ref(label),
mode: ref(mode), mode: ref(mode),
type: ref(type), type: ref(type),
index: ref(index),
isConnected: ref(isConnected), isConnected: ref(isConnected),
isConnecting: ref(isConnecting), isConnecting: ref(isConnecting),
runData: ref(runData),
isReadOnly: ref(isReadOnly), isReadOnly: ref(isReadOnly),
} satisfies CanvasNodeHandleInjectionData, } satisfies CanvasNodeHandleInjectionData,
}; };

View file

@ -3,7 +3,6 @@
import { computed, h, provide, toRef, useCssModule } from 'vue'; import { computed, h, provide, toRef, useCssModule } from 'vue';
import type { CanvasConnectionPort, CanvasElementPortWithRenderData } from '@/types'; import type { CanvasConnectionPort, CanvasElementPortWithRenderData } from '@/types';
import { CanvasConnectionMode } from '@/types'; import { CanvasConnectionMode } from '@/types';
import type { ValidConnectionFunc } from '@vue-flow/core'; import type { ValidConnectionFunc } from '@vue-flow/core';
import { Handle } from '@vue-flow/core'; import { Handle } from '@vue-flow/core';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
@ -13,6 +12,7 @@ import CanvasHandleNonMainInput from '@/components/canvas/elements/handles/rende
import CanvasHandleNonMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMainOutput.vue'; import CanvasHandleNonMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMainOutput.vue';
import { CanvasNodeHandleKey } from '@/constants'; import { CanvasNodeHandleKey } from '@/constants';
import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2'; import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
import { useCanvasNode } from '@/composables/useCanvasNode';
const props = defineProps<{ const props = defineProps<{
mode: CanvasConnectionMode; mode: CanvasConnectionMode;
@ -59,6 +59,18 @@ const isConnectableEnd = computed(() => {
const handleClasses = computed(() => [style.handle, style[props.type], style[props.mode]]); const handleClasses = computed(() => [style.handle, style[props.type], style[props.mode]]);
/**
* Run data
*/
const { runDataOutputMap } = useCanvasNode();
const runData = computed(() =>
props.mode === CanvasConnectionMode.Output
? runDataOutputMap.value[props.type]?.[props.index]
: undefined,
);
/** /**
* Render additional elements * Render additional elements
*/ */
@ -103,11 +115,14 @@ const isConnecting = toRef(props, 'isConnecting');
const isReadOnly = toRef(props, 'isReadOnly'); const isReadOnly = toRef(props, 'isReadOnly');
const mode = toRef(props, 'mode'); const mode = toRef(props, 'mode');
const type = toRef(props, 'type'); const type = toRef(props, 'type');
const index = toRef(props, 'index');
provide(CanvasNodeHandleKey, { provide(CanvasNodeHandleKey, {
label, label,
mode, mode,
type, type,
index,
runData,
isConnected, isConnected,
isConnecting, isConnecting,
isReadOnly, isReadOnly,

View file

@ -31,4 +31,36 @@ describe('CanvasHandleMainOutput', () => {
expect(queryByTestId('canvas-handle-plus')).not.toBeInTheDocument(); expect(queryByTestId('canvas-handle-plus')).not.toBeInTheDocument();
}); });
it('should render run data label', async () => {
const runData = {
total: 1,
iterations: 1,
};
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasHandleProvide({ label: '', runData }),
},
},
});
expect(getByText('1 item')).toBeInTheDocument();
});
it('should not render run data label if output label is available', async () => {
const runData = {
total: 1,
iterations: 1,
};
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasHandleProvide({ label: 'Output', runData }),
},
},
});
expect(() => getByText('1 item')).toThrow();
expect(getByText('Output')).toBeInTheDocument();
});
}); });

View file

@ -3,28 +3,41 @@ import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
import { useCanvasNode } from '@/composables/useCanvasNode'; import { useCanvasNode } from '@/composables/useCanvasNode';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import type { CanvasNodeDefaultRender } from '@/types'; import type { CanvasNodeDefaultRender } from '@/types';
import { useI18n } from '@/composables/useI18n';
const emit = defineEmits<{ const emit = defineEmits<{
add: []; add: [];
}>(); }>();
const i18n = useI18n();
const { render } = useCanvasNode(); const { render } = useCanvasNode();
const { label, isConnected, isConnecting, isReadOnly } = useCanvasNodeHandle(); const { label, isConnected, isConnecting, isReadOnly, runData } = useCanvasNodeHandle();
const handleClasses = 'source'; const handleClasses = 'source';
const isHovered = ref(false); const isHovered = ref(false);
const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']); const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']);
const runDataLabel = computed(() =>
runData.value
? i18n.baseText('ndv.output.items', {
adjustToNumber: runData.value.total,
interpolate: { count: String(runData.value.total) },
})
: '',
);
const isHandlePlusVisible = computed(() => !isConnecting.value || isHovered.value); const isHandlePlusVisible = computed(() => !isConnecting.value || isHovered.value);
const plusState = computed(() => (runData.value ? 'success' : 'default'));
const plusLineSize = computed( const plusLineSize = computed(
() => () =>
({ ({
small: 46, small: 46,
medium: 66, medium: 66,
large: 80, large: 80,
})[renderOptions.value.outputs?.labelSize ?? 'small'], })[renderOptions.value.outputs?.labelSize ?? runData.value ? 'large' : 'small'],
); );
function onMouseEnter() { function onMouseEnter() {
@ -41,7 +54,8 @@ function onClickAdd() {
</script> </script>
<template> <template>
<div :class="['canvas-node-handle-main-output', $style.handle]"> <div :class="['canvas-node-handle-main-output', $style.handle]">
<div :class="[$style.label]">{{ label }}</div> <div v-if="label" :class="[$style.label, $style.outputLabel]">{{ label }}</div>
<div v-else-if="runData" :class="[$style.label, $style.runDataLabel]">{{ runDataLabel }}</div>
<CanvasHandleDot :handle-classes="handleClasses" /> <CanvasHandleDot :handle-classes="handleClasses" />
<Transition name="canvas-node-handle-main-output"> <Transition name="canvas-node-handle-main-output">
<CanvasHandlePlus <CanvasHandlePlus
@ -50,6 +64,7 @@ function onClickAdd() {
data-test-id="canvas-handle-plus" data-test-id="canvas-handle-plus"
:line-size="plusLineSize" :line-size="plusLineSize"
:handle-classes="handleClasses" :handle-classes="handleClasses"
:state="plusState"
@mouseenter="onMouseEnter" @mouseenter="onMouseEnter"
@mouseleave="onMouseLeave" @mouseleave="onMouseLeave"
@click:plus="onClickAdd" @click:plus="onClickAdd"
@ -68,11 +83,6 @@ function onClickAdd() {
.label { .label {
position: absolute; position: absolute;
top: 50%;
left: var(--spacing-m);
transform: translate(0, -50%);
font-size: var(--font-size-2xs);
color: var(--color-foreground-xdark);
background: var(--color-canvas-label-background); background: var(--color-canvas-label-background);
z-index: 1; z-index: 1;
max-width: calc(100% - var(--spacing-m) - 24px); max-width: calc(100% - var(--spacing-m) - 24px);
@ -80,6 +90,23 @@ function onClickAdd() {
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
} }
.outputLabel {
top: 50%;
left: var(--spacing-m);
transform: translate(0, -50%);
font-size: var(--font-size-2xs);
color: var(--color-foreground-xdark);
}
.runDataLabel {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
font-size: var(--font-size-xs);
color: var(--color-success);
}
</style> </style>
<style lang="scss"> <style lang="scss">

View file

@ -40,6 +40,14 @@ describe('CanvasHandlePlus', () => {
}); });
}); });
it('should apply correct classes based on state', () => {
const { container } = renderComponent({
props: { state: 'success' },
});
expect(container.firstChild).toHaveClass('success');
});
it('should render SVG elements correctly', () => { it('should render SVG elements correctly', () => {
const { container } = renderComponent(); const { container } = renderComponent();

View file

@ -7,12 +7,14 @@ const props = withDefaults(
handleClasses?: string; handleClasses?: string;
plusSize?: number; plusSize?: number;
lineSize?: number; lineSize?: number;
state?: 'success' | 'default';
}>(), }>(),
{ {
position: 'right', position: 'right',
handleClasses: undefined, handleClasses: undefined,
plusSize: 24, plusSize: 24,
lineSize: 46, lineSize: 46,
state: 'default',
}, },
); );
@ -22,7 +24,12 @@ const emit = defineEmits<{
const style = useCssModule(); const style = useCssModule();
const classes = computed(() => [style.wrapper, style[props.position], props.handleClasses]); const classes = computed(() => [
style.wrapper,
style[props.position],
style[props.state],
props.handleClasses,
]);
const viewBox = computed(() => { const viewBox = computed(() => {
switch (props.position) { switch (props.position) {
@ -91,7 +98,7 @@ function onClick(event: MouseEvent) {
<template> <template>
<svg :class="classes" :viewBox="`0 0 ${viewBox.width} ${viewBox.height}`" :style="styles"> <svg :class="classes" :viewBox="`0 0 ${viewBox.width} ${viewBox.height}`" :style="styles">
<line <line
:class="handleClasses" :class="[handleClasses, $style.line]"
:x1="linePosition[0][0]" :x1="linePosition[0][0]"
:y1="linePosition[0][1]" :y1="linePosition[0][1]"
:x2="linePosition[1][0]" :x2="linePosition[1][0]"
@ -127,6 +134,10 @@ function onClick(event: MouseEvent) {
<style lang="scss" module> <style lang="scss" module>
.wrapper { .wrapper {
position: relative; position: relative;
&.success .line {
stroke: var(--color-success);
}
} }
.plus { .plus {

View file

@ -1,8 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasHandleDiamond > should render with default props 1`] = ` exports[`CanvasHandleDiamond > should render with default props 1`] = `
"<svg class="wrapper right" viewBox="0 0 70 24" style="width: 70px; height: 24px;"> "<svg class="wrapper right default" viewBox="0 0 70 24" style="width: 70px; height: 24px;">
<line class="" x1="0" y1="12" x2="47" y2="12" stroke="var(--color-foreground-xdark)" stroke-width="2"></line> <line class="line" x1="0" y1="12" x2="47" y2="12" stroke="var(--color-foreground-xdark)" stroke-width="2"></line>
<g class="plus clickable" transform="translate(46, 0)"> <g class="plus clickable" transform="translate(46, 0)">
<rect class="clickable" x="2" y="2" width="20" height="20" stroke="var(--color-foreground-xdark)" stroke-width="2" rx="4" fill="#ffffff"></rect> <rect class="clickable" x="2" y="2" width="20" height="20" stroke="var(--color-foreground-xdark)" stroke-width="2" rx="4" fill="#ffffff"></rect>
<path class="clickable" fill="var(--color-foreground-xdark)" d="m16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z"></path> <path class="clickable" fill="var(--color-foreground-xdark)" d="m16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z"></path>

View file

@ -1,8 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasHandlePlus > should render with default props 1`] = ` exports[`CanvasHandlePlus > should render with default props 1`] = `
"<svg class="wrapper right" viewBox="0 0 70 24" style="width: 70px; height: 24px;"> "<svg class="wrapper right default" viewBox="0 0 70 24" style="width: 70px; height: 24px;">
<line class="" x1="0" y1="12" x2="47" y2="12" stroke="var(--color-foreground-xdark)" stroke-width="2"></line> <line class="line" x1="0" y1="12" x2="47" y2="12" stroke="var(--color-foreground-xdark)" stroke-width="2"></line>
<g class="plus clickable" transform="translate(46, 0)"> <g class="plus clickable" transform="translate(46, 0)">
<rect class="clickable" x="2" y="2" width="20" height="20" stroke="var(--color-foreground-xdark)" stroke-width="2" rx="4" fill="#ffffff"></rect> <rect class="clickable" x="2" y="2" width="20" height="20" stroke="var(--color-foreground-xdark)" stroke-width="2" rx="4" fill="#ffffff"></rect>
<path class="clickable" fill="var(--color-foreground-xdark)" d="m16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z"></path> <path class="clickable" fill="var(--color-foreground-xdark)" d="m16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z"></path>

View file

@ -31,7 +31,9 @@ describe('CanvasNodeStatusIcons', () => {
it('should render correctly for a node that ran successfully', () => { it('should render correctly for a node that ran successfully', () => {
const { getByTestId } = renderComponent({ const { getByTestId } = renderComponent({
global: { global: {
provide: createCanvasNodeProvide({ data: { runData: { count: 15, visible: true } } }), provide: createCanvasNodeProvide({
data: { runData: { outputMap: {}, iterations: 15, visible: true } },
}),
}, },
}); });

View file

@ -14,7 +14,7 @@ const {
executionWaiting, executionWaiting,
executionRunning, executionRunning,
hasRunData, hasRunData,
runDataCount, runDataIterations,
} = useCanvasNode(); } = useCanvasNode();
const hideNodeIssues = computed(() => false); // @TODO Implement this const hideNodeIssues = computed(() => false); // @TODO Implement this
@ -67,7 +67,7 @@ const hideNodeIssues = computed(() => false); // @TODO Implement this
:class="[$style.status, $style.runData]" :class="[$style.status, $style.runData]"
> >
<FontAwesomeIcon icon="check" /> <FontAwesomeIcon icon="check" />
<span v-if="runDataCount > 1" :class="$style.count"> {{ runDataCount }}</span> <span v-if="runDataIterations > 1" :class="$style.count"> {{ runDataIterations }}</span>
</div> </div>
</template> </template>

View file

@ -2,18 +2,18 @@ import type { Ref } from 'vue';
import { ref } from 'vue'; import { ref } from 'vue';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import type { Workflow } from 'n8n-workflow'; import type { Workflow } from 'n8n-workflow';
import { createPinia, setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import { useCanvasMapping } from '@/composables/useCanvasMapping'; import { useCanvasMapping } from '@/composables/useCanvasMapping';
import type { INodeUi } from '@/Interface'; import type { INodeUi } from '@/Interface';
import { import {
createTestNode,
createTestWorkflowObject, createTestWorkflowObject,
mockNode, mockNode,
mockNodes, mockNodes,
mockNodeTypeDescription, mockNodeTypeDescription,
} from '@/__tests__/mocks'; } from '@/__tests__/mocks';
import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants'; import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE, STICKY_NODE_TYPE, STORES } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { import {
createCanvasConnectionHandleString, createCanvasConnectionHandleString,
@ -21,19 +21,32 @@ import {
} from '@/utils/canvasUtilsV2'; } from '@/utils/canvasUtilsV2';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types'; import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import { MarkerType } from '@vue-flow/core'; import { MarkerType } from '@vue-flow/core';
import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
beforeEach(() => { beforeEach(() => {
const pinia = createPinia(); const pinia = createTestingPinia({
setActivePinia(pinia); initialState: {
[STORES.WORKFLOWS]: {
useNodeTypesStore().setNodeTypes([ workflowExecutionData: {},
mockNodeTypeDescription({ },
[STORES.NODE_TYPES]: {
nodeTypes: {
[MANUAL_TRIGGER_NODE_TYPE]: {
1: mockNodeTypeDescription({
name: MANUAL_TRIGGER_NODE_TYPE, name: MANUAL_TRIGGER_NODE_TYPE,
}), }),
mockNodeTypeDescription({ },
[SET_NODE_TYPE]: {
1: mockNodeTypeDescription({
name: SET_NODE_TYPE, name: SET_NODE_TYPE,
}), }),
]); },
},
},
},
});
setActivePinia(pinia);
}); });
afterEach(() => { afterEach(() => {
@ -61,6 +74,7 @@ describe('useCanvasMapping', () => {
describe('nodes', () => { describe('nodes', () => {
it('should map nodes to canvas nodes', () => { it('should map nodes to canvas nodes', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const manualTriggerNode = mockNode({ const manualTriggerNode = mockNode({
name: 'Manual Trigger', name: 'Manual Trigger',
type: MANUAL_TRIGGER_NODE_TYPE, type: MANUAL_TRIGGER_NODE_TYPE,
@ -79,6 +93,8 @@ describe('useCanvasMapping', () => {
workflowObject: ref(workflowObject) as Ref<Workflow>, workflowObject: ref(workflowObject) as Ref<Workflow>,
}); });
workflowsStore.isNodeExecuting.mockReturnValue(false);
expect(mappedNodes.value).toEqual([ expect(mappedNodes.value).toEqual([
{ {
id: manualTriggerNode.id, id: manualTriggerNode.id,
@ -106,7 +122,8 @@ describe('useCanvasMapping', () => {
visible: false, visible: false,
}, },
runData: { runData: {
count: 0, iterations: 0,
outputMap: {},
visible: false, visible: false,
}, },
inputs: [ inputs: [
@ -169,6 +186,7 @@ describe('useCanvasMapping', () => {
}); });
it('should handle execution state', () => { it('should handle execution state', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const manualTriggerNode = mockNode({ const manualTriggerNode = mockNode({
name: 'Manual Trigger', name: 'Manual Trigger',
type: MANUAL_TRIGGER_NODE_TYPE, type: MANUAL_TRIGGER_NODE_TYPE,
@ -181,7 +199,7 @@ describe('useCanvasMapping', () => {
connections, connections,
}); });
useWorkflowsStore().addExecutingNode(manualTriggerNode.name); workflowsStore.isNodeExecuting.mockReturnValue(true);
const { nodes: mappedNodes } = useCanvasMapping({ const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes), nodes: ref(nodes),
@ -336,6 +354,195 @@ describe('useCanvasMapping', () => {
}); });
}); });
}); });
describe('runData', () => {
describe('nodeExecutionRunDataOutputMapById', () => {
it('should return an empty object when there is no run data', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const nodes: INodeUi[] = [];
const connections = {};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
});
workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue(null);
const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeExecutionRunDataOutputMapById.value).toEqual({});
});
it('should calculate iterations and total correctly for single node', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const nodes = [createTestNode({ name: 'Node 1' })];
const connections = {};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
});
workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([
{
startTime: 0,
executionTime: 0,
source: [],
data: {
[NodeConnectionType.Main]: [[{ json: {} }, { json: {} }]],
},
},
]);
const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeExecutionRunDataOutputMapById.value).toEqual({
[nodes[0].id]: {
[NodeConnectionType.Main]: {
0: {
iterations: 1,
total: 2,
},
},
},
});
});
it('should handle multiple nodes with different connection types', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const nodes = [
createTestNode({ id: 'node1', name: 'Node 1' }),
createTestNode({ id: 'node2', name: 'Node 2' }),
];
const connections = {};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
});
workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => {
if (nodeName === 'Node 1') {
return [
{
startTime: 0,
executionTime: 0,
source: [],
data: {
[NodeConnectionType.Main]: [[{ json: {} }]],
[NodeConnectionType.AiAgent]: [[{ json: {} }, { json: {} }]],
},
},
];
} else if (nodeName === 'Node 2') {
return [
{
startTime: 0,
executionTime: 0,
source: [],
data: {
[NodeConnectionType.Main]: [[{ json: {} }, { json: {} }, { json: {} }]],
},
},
];
}
return null;
});
const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeExecutionRunDataOutputMapById.value).toEqual({
node1: {
[NodeConnectionType.Main]: {
0: {
iterations: 1,
total: 1,
},
},
[NodeConnectionType.AiAgent]: {
0: {
iterations: 1,
total: 2,
},
},
},
node2: {
[NodeConnectionType.Main]: {
0: {
iterations: 1,
total: 3,
},
},
},
});
});
it('handles multiple iterations correctly', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const nodes = [createTestNode({ name: 'Node 1' })];
const connections = {};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
});
workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([
{
startTime: 0,
executionTime: 0,
source: [],
data: {
[NodeConnectionType.Main]: [[{ json: {} }]],
},
},
{
startTime: 0,
executionTime: 0,
source: [],
data: {
[NodeConnectionType.Main]: [[{ json: {} }, { json: {} }, { json: {} }]],
},
},
{
startTime: 0,
executionTime: 0,
source: [],
data: {
[NodeConnectionType.Main]: [[{ json: {} }, { json: {} }]],
},
},
]);
const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeExecutionRunDataOutputMapById.value).toEqual({
[nodes[0].id]: {
[NodeConnectionType.Main]: {
0: {
iterations: 3,
total: 6,
},
},
},
});
});
});
});
}); });
describe('connections', () => { describe('connections', () => {

View file

@ -18,11 +18,13 @@ import type {
CanvasNodeDefaultRender, CanvasNodeDefaultRender,
CanvasNodeDefaultRenderLabelSize, CanvasNodeDefaultRenderLabelSize,
CanvasNodeStickyNoteRender, CanvasNodeStickyNoteRender,
ExecutionOutputMap,
} from '@/types'; } from '@/types';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types'; import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import { import {
mapLegacyConnectionsToCanvasConnections, mapLegacyConnectionsToCanvasConnections,
mapLegacyEndpointsToCanvasConnectionPort, mapLegacyEndpointsToCanvasConnectionPort,
parseCanvasConnectionHandleString,
} from '@/utils/canvasUtilsV2'; } from '@/utils/canvasUtilsV2';
import type { import type {
ExecutionStatus, ExecutionStatus,
@ -241,6 +243,39 @@ export function useCanvasMapping({
}, {}), }, {}),
); );
const nodeExecutionRunDataOutputMapById = computed(() =>
Object.keys(nodeExecutionRunDataById.value).reduce<Record<string, ExecutionOutputMap>>(
(acc, nodeId) => {
acc[nodeId] = {};
const outputData = { iterations: 0, total: 0 };
for (const runIteration of nodeExecutionRunDataById.value[nodeId] ?? []) {
const data = runIteration.data ?? {};
for (const connectionType of Object.keys(data)) {
const connectionTypeData = data[connectionType] ?? {};
acc[nodeId][connectionType] = acc[nodeId][connectionType] ?? {};
for (const outputIndex of Object.keys(connectionTypeData)) {
const parsedOutputIndex = parseInt(outputIndex, 10);
const connectionTypeOutputIndexData = connectionTypeData[parsedOutputIndex] ?? [];
acc[nodeId][connectionType][outputIndex] = acc[nodeId][connectionType][
outputIndex
] ?? { ...outputData };
acc[nodeId][connectionType][outputIndex].iterations += 1;
acc[nodeId][connectionType][outputIndex].total +=
connectionTypeOutputIndexData.length;
}
}
}
return acc;
},
{},
),
);
const nodeIssuesById = computed(() => const nodeIssuesById = computed(() =>
nodes.value.reduce<Record<string, string[]>>((acc, node) => { nodes.value.reduce<Record<string, string[]>>((acc, node) => {
const issues: string[] = []; const issues: string[] = [];
@ -359,7 +394,8 @@ export function useCanvasMapping({
running: nodeExecutionRunningById.value[node.id], running: nodeExecutionRunningById.value[node.id],
}, },
runData: { runData: {
count: nodeExecutionRunDataById.value[node.id]?.length ?? 0, outputMap: nodeExecutionRunDataOutputMapById.value[node.id],
iterations: nodeExecutionRunDataById.value[node.id]?.length ?? 0,
visible: !!nodeExecutionRunDataById.value[node.id], visible: !!nodeExecutionRunDataById.value[node.id],
}, },
render: renderTypeByNodeId.value[node.id] ?? { type: 'default', options: {} }, render: renderTypeByNodeId.value[node.id] ?? { type: 'default', options: {} },
@ -426,7 +462,6 @@ export function useCanvasMapping({
function getConnectionLabel(connection: CanvasConnection): string { function getConnectionLabel(connection: CanvasConnection): string {
const fromNode = nodes.value.find((node) => node.name === connection.data?.fromNodeName); const fromNode = nodes.value.find((node) => node.name === connection.data?.fromNodeName);
if (!fromNode) { if (!fromNode) {
return ''; return '';
} }
@ -438,10 +473,13 @@ export function useCanvasMapping({
interpolate: { count: String(pinnedDataCount) }, interpolate: { count: String(pinnedDataCount) },
}); });
} else if (nodeExecutionRunDataById.value[fromNode.id]) { } else if (nodeExecutionRunDataById.value[fromNode.id]) {
const runDataCount = nodeExecutionRunDataById.value[fromNode.id]?.length ?? 0; const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle);
const runDataTotal =
nodeExecutionRunDataOutputMapById.value[fromNode.id]?.[type]?.[index]?.total ?? 0;
return i18n.baseText('ndv.output.items', { return i18n.baseText('ndv.output.items', {
adjustToNumber: runDataCount, adjustToNumber: runDataTotal,
interpolate: { count: String(runDataCount) }, interpolate: { count: String(runDataTotal) },
}); });
} }
@ -449,6 +487,7 @@ export function useCanvasMapping({
} }
return { return {
nodeExecutionRunDataOutputMapById,
connections: mappedConnections, connections: mappedConnections,
nodes: mappedNodes, nodes: mappedNodes,
}; };

View file

@ -27,7 +27,8 @@ describe('useCanvasNode', () => {
expect(result.isSelected.value).toBeUndefined(); expect(result.isSelected.value).toBeUndefined();
expect(result.pinnedDataCount.value).toBe(0); expect(result.pinnedDataCount.value).toBe(0);
expect(result.hasPinnedData.value).toBe(false); expect(result.hasPinnedData.value).toBe(false);
expect(result.runDataCount.value).toBe(0); expect(result.runDataOutputMap.value).toEqual({});
expect(result.runDataIterations.value).toBe(0);
expect(result.hasRunData.value).toBe(false); expect(result.hasRunData.value).toBe(false);
expect(result.issues.value).toEqual([]); expect(result.issues.value).toEqual([]);
expect(result.hasIssues.value).toBe(false); expect(result.hasIssues.value).toBe(false);
@ -54,7 +55,7 @@ describe('useCanvasNode', () => {
}, },
issues: { items: ['issue1'], visible: true }, issues: { items: ['issue1'], visible: true },
execution: { status: 'running', waiting: 'waiting', running: true }, execution: { status: 'running', waiting: 'waiting', running: true },
runData: { count: 1, visible: true }, runData: { outputMap: {}, iterations: 1, visible: true },
pinnedData: { count: 1, visible: true }, pinnedData: { count: 1, visible: true },
render: { render: {
type: CanvasNodeRenderType.Default, type: CanvasNodeRenderType.Default,
@ -86,7 +87,8 @@ describe('useCanvasNode', () => {
expect(result.isSelected.value).toBe(true); expect(result.isSelected.value).toBe(true);
expect(result.pinnedDataCount.value).toBe(1); expect(result.pinnedDataCount.value).toBe(1);
expect(result.hasPinnedData.value).toBe(true); expect(result.hasPinnedData.value).toBe(true);
expect(result.runDataCount.value).toBe(1); expect(result.runDataOutputMap.value).toEqual({});
expect(result.runDataIterations.value).toBe(1);
expect(result.hasRunData.value).toBe(true); expect(result.hasRunData.value).toBe(true);
expect(result.issues.value).toEqual(['issue1']); expect(result.issues.value).toEqual(['issue1']);
expect(result.hasIssues.value).toBe(true); expect(result.hasIssues.value).toBe(true);

View file

@ -10,9 +10,10 @@ import { CanvasNodeRenderType, CanvasConnectionMode } from '@/types';
export function useCanvasNode() { export function useCanvasNode() {
const node = inject(CanvasNodeKey); const node = inject(CanvasNodeKey);
const data = computed<CanvasNodeData>( const data = computed(
() => () =>
node?.data.value ?? { node?.data.value ??
({
id: '', id: '',
name: '', name: '',
subtitle: '', subtitle: '',
@ -27,12 +28,12 @@ export function useCanvasNode() {
execution: { execution: {
running: false, running: false,
}, },
runData: { count: 0, visible: false }, runData: { iterations: 0, outputMap: {}, visible: false },
render: { render: {
type: CanvasNodeRenderType.Default, type: CanvasNodeRenderType.Default,
options: {}, options: {},
}, },
}, } satisfies CanvasNodeData),
); );
const id = computed(() => node?.id.value ?? ''); const id = computed(() => node?.id.value ?? '');
@ -58,7 +59,8 @@ export function useCanvasNode() {
const executionWaiting = computed(() => data.value.execution.waiting); const executionWaiting = computed(() => data.value.execution.waiting);
const executionRunning = computed(() => data.value.execution.running); const executionRunning = computed(() => data.value.execution.running);
const runDataCount = computed(() => data.value.runData.count); const runDataOutputMap = computed(() => data.value.runData.outputMap);
const runDataIterations = computed(() => data.value.runData.iterations);
const hasRunData = computed(() => data.value.runData.visible); const hasRunData = computed(() => data.value.runData.visible);
const render = computed(() => data.value.render); const render = computed(() => data.value.render);
@ -79,7 +81,8 @@ export function useCanvasNode() {
isSelected, isSelected,
pinnedDataCount, pinnedDataCount,
hasPinnedData, hasPinnedData,
runDataCount, runDataIterations,
runDataOutputMap,
hasRunData, hasRunData,
issues, issues,
hasIssues, hasIssues,

View file

@ -14,9 +14,11 @@ export function useCanvasNodeHandle() {
const label = computed(() => handle?.label.value ?? ''); const label = computed(() => handle?.label.value ?? '');
const isConnected = computed(() => handle?.isConnected.value ?? false); const isConnected = computed(() => handle?.isConnected.value ?? false);
const isConnecting = computed(() => handle?.isConnecting.value ?? false); const isConnecting = computed(() => handle?.isConnecting.value ?? false);
const isReadOnly = computed(() => handle?.isReadOnly.value);
const type = computed(() => handle?.type.value ?? NodeConnectionType.Main); const type = computed(() => handle?.type.value ?? NodeConnectionType.Main);
const mode = computed(() => handle?.mode.value ?? CanvasConnectionMode.Input); const mode = computed(() => handle?.mode.value ?? CanvasConnectionMode.Input);
const isReadOnly = computed(() => handle?.isReadOnly.value); const index = computed(() => handle?.index.value ?? 0);
const runData = computed(() => handle?.runData.value);
return { return {
label, label,
@ -25,5 +27,7 @@ export function useCanvasNodeHandle() {
isReadOnly, isReadOnly,
type, type,
mode, mode,
index,
runData,
}; };
} }

View file

@ -103,7 +103,8 @@ export interface CanvasNodeData {
running: boolean; running: boolean;
}; };
runData: { runData: {
count: number; outputMap: ExecutionOutputMap;
iterations: number;
visible: boolean; visible: boolean;
}; };
render: CanvasNodeDefaultRender | CanvasNodeStickyNoteRender | CanvasNodeAddNodesRender; render: CanvasNodeDefaultRender | CanvasNodeStickyNoteRender | CanvasNodeAddNodesRender;
@ -163,11 +164,24 @@ export interface CanvasNodeHandleInjectionData {
label: Ref<string | undefined>; label: Ref<string | undefined>;
mode: Ref<CanvasConnectionMode>; mode: Ref<CanvasConnectionMode>;
type: Ref<NodeConnectionType>; type: Ref<NodeConnectionType>;
index: Ref<number>;
isConnected: Ref<boolean | undefined>; isConnected: Ref<boolean | undefined>;
isConnecting: Ref<boolean | undefined>; isConnecting: Ref<boolean | undefined>;
isReadOnly: Ref<boolean | undefined>; isReadOnly: Ref<boolean | undefined>;
runData: Ref<ExecutionOutputMapData | undefined>;
} }
export type ConnectStartEvent = { handleId: string; handleType: string; nodeId: string }; export type ConnectStartEvent = { handleId: string; handleType: string; nodeId: string };
export type CanvasNodeMoveEvent = { id: string; position: CanvasNode['position'] }; export type CanvasNodeMoveEvent = { id: string; position: CanvasNode['position'] };
export type ExecutionOutputMapData = {
total: number;
iterations: number;
};
export type ExecutionOutputMap = {
[connectionType: string]: {
[outputIndex: string]: ExecutionOutputMapData;
};
};