feat(editor): Indicate dirty nodes with yellow borders/connectors on canvas (#13040)

This commit is contained in:
autologie 2025-02-26 16:35:17 +01:00 committed by GitHub
parent acdd2da492
commit 75493ef6ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1247 additions and 166 deletions

View file

@ -206,6 +206,10 @@ export function clickWorkflowCardContent(workflowName: string) {
getWorkflowCardContent(workflowName).click();
}
export function clickAssignmentCollectionAdd() {
cy.getByTestId('assignment-collection-drop-area').click();
}
export function assertNodeOutputHintExists() {
getNodeOutputHint().should('exist');
}

View file

@ -1,3 +1,16 @@
import {
clickAssignmentCollectionAdd,
clickGetBackToCanvas,
getNodeRunInfoStale,
getOutputTbodyCell,
} from '../composables/ndv';
import {
clickExecuteWorkflowButton,
getNodeByName,
getZoomToFitButton,
navigateToNewWorkflowPage,
openNode,
} from '../composables/workflow';
import { NDV, WorkflowPage } from '../pages';
const canvas = new WorkflowPage();
@ -26,4 +39,53 @@ describe('Manual partial execution', () => {
ndv.getters.nodeRunTooltipIndicator().should('not.exist');
ndv.getters.outputRunSelector().should('not.exist');
});
describe('partial execution v2', () => {
beforeEach(() => {
cy.window().then((win) => {
win.localStorage.setItem('PartialExecution.version', '2');
});
navigateToNewWorkflowPage();
});
it('should execute from the first dirty node up to the current node', () => {
cy.createFixtureWorkflow('Test_workflow_partial_execution_v2.json');
getZoomToFitButton().click();
// First, execute the whole workflow
clickExecuteWorkflowButton();
getNodeByName('A').findChildByTestId('canvas-node-status-success').should('be.visible');
getNodeByName('B').findChildByTestId('canvas-node-status-success').should('be.visible');
getNodeByName('C').findChildByTestId('canvas-node-status-success').should('be.visible');
openNode('A');
getOutputTbodyCell(1, 0).invoke('text').as('before', { type: 'static' });
clickGetBackToCanvas();
// Change parameter of the node in the middle
openNode('B');
clickAssignmentCollectionAdd();
getNodeRunInfoStale().should('be.visible');
clickGetBackToCanvas();
getNodeByName('A').findChildByTestId('canvas-node-status-success').should('be.visible');
getNodeByName('B').findChildByTestId('canvas-node-status-warning').should('be.visible');
getNodeByName('C').findChildByTestId('canvas-node-status-success').should('be.visible');
// Partial execution
getNodeByName('C').findChildByTestId('execute-node-button').click();
getNodeByName('A').findChildByTestId('canvas-node-status-success').should('be.visible');
getNodeByName('B').findChildByTestId('canvas-node-status-success').should('be.visible');
getNodeByName('C').findChildByTestId('canvas-node-status-success').should('be.visible');
openNode('A');
getOutputTbodyCell(1, 0).invoke('text').as('after', { type: 'static' });
// Assert that 'A' ran only once by comparing its output
cy.get('@before').then((before) =>
cy.get('@after').then((after) => expect(before).to.equal(after)),
);
});
});
});

View file

@ -0,0 +1,74 @@
{
"nodes": [
{
"parameters": {
"rule": {
"interval": [{}]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [0, 0],
"id": "dcc1c5e1-c6c1-45f8-80d5-65c88d66d56e",
"name": "A"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "3d8f0810-84f0-41ce-a81b-0e7f04fd88cb",
"name": "",
"value": "",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [220, 0],
"id": "097ffa30-d37b-4de6-bd5c-ccd945f31df1",
"name": "B"
},
{
"parameters": {
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [440, 0],
"id": "dc44e635-916f-4f76-a745-1add5762f730",
"name": "C"
}
],
"connections": {
"A": {
"main": [
[
{
"node": "B",
"type": "main",
"index": 0
}
]
]
},
"B": {
"main": [
[
{
"node": "C",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"instanceId": "b0d9447cff9c96796e4ac4f00fcd899b03cfac3ab3d4f748ae686d34881eae0c"
}
}

View file

@ -7,7 +7,7 @@ import type { IconColor } from 'n8n-design-system/types/icon';
import N8nIcon from '../N8nIcon';
import N8nTooltip from '../N8nTooltip';
const THEME = ['info', 'info-light', 'warning', 'danger', 'success'] as const;
const THEME = ['info', 'info-light', 'warning', 'warning-light', 'danger', 'success'] as const;
const TYPE = ['note', 'tooltip'] as const;
const ICON_MAP = {
@ -15,9 +15,23 @@ const ICON_MAP = {
// eslint-disable-next-line @typescript-eslint/naming-convention
'info-light': 'info-circle',
warning: 'exclamation-triangle',
// eslint-disable-next-line @typescript-eslint/naming-convention
'warning-light': 'triangle', // NOTE: This requires a custom icon
danger: 'exclamation-triangle',
success: 'check-circle',
} as const;
const COLOR_MAP: Record<keyof IconMap, IconColor> = {
info: 'text-base',
// eslint-disable-next-line @typescript-eslint/naming-convention
'info-light': 'text-base',
warning: 'warning',
// eslint-disable-next-line @typescript-eslint/naming-convention
'warning-light': 'warning',
danger: 'danger',
success: 'success',
};
type IconMap = typeof ICON_MAP;
interface InfoTipProps {
@ -40,7 +54,7 @@ const props = withDefaults(defineProps<InfoTipProps>(), {
const iconData = computed<{ icon: IconMap[keyof IconMap]; color: IconColor }>(() => {
return {
icon: ICON_MAP[props.theme],
color: props.theme === 'info' || props.theme === 'info-light' ? 'text-base' : props.theme,
color: COLOR_MAP[props.theme],
} as const;
});
</script>

View file

@ -884,6 +884,8 @@ export interface ITemplatesNode extends IVersionNode {
export interface INodeMetadata {
parametersLastUpdatedAt?: number;
pinnedDataLastUpdatedAt?: number;
pinnedDataLastRemovedAt?: number;
pristine: boolean;
}

View file

@ -12,7 +12,7 @@ import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { waitingNodeTooltip } from '@/utils/executionUtils';
import { uniqBy } from 'lodash-es';
import { N8nRadioButtons, N8nText, N8nTooltip } from 'n8n-design-system';
import { N8nIcon, N8nRadioButtons, N8nText, N8nTooltip } from 'n8n-design-system';
import type { INodeInputConfiguration, INodeOutputConfiguration, Workflow } from 'n8n-workflow';
import { NodeConnectionType, NodeHelpers } from 'n8n-workflow';
import { storeToRefs } from 'pinia';
@ -464,7 +464,16 @@ function activatePane() {
/>
</N8nTooltip>
<N8nText v-if="!readOnly" tag="div" size="small">
{{ i18n.baseText('ndv.input.noOutputData.hint') }}
<i18n-t keypath="ndv.input.noOutputData.hint">
<template #info>
<N8nTooltip placement="bottom">
<template #content>
{{ i18n.baseText('ndv.input.noOutputData.hint.tooltip') }}
</template>
<N8nIcon icon="question-circle" />
</N8nTooltip>
</template>
</i18n-t>
</N8nText>
</div>
<div v-else :class="$style.notConnected">

View file

@ -765,7 +765,7 @@ const credentialSelected = (updateInformation: INodeUpdatePropertiesInformation)
const nameChanged = (name: string) => {
if (node.value) {
historyStore.pushCommandToUndo(new RenameNodeCommand(node.value.name, name));
historyStore.pushCommandToUndo(new RenameNodeCommand(node.value.name, name, Date.now()));
}
valueChanged({
value: name,

View file

@ -21,6 +21,9 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@/composables/useI18n';
import { waitingNodeTooltip } from '@/utils/executionUtils';
import { N8nRadioButtons, N8nText } from 'n8n-design-system';
import { useSettingsStore } from '@/stores/settings.store';
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
import { CanvasNodeDirtiness } from '@/types';
// Types
@ -75,6 +78,8 @@ const uiStore = useUIStore();
const telemetry = useTelemetry();
const i18n = useI18n();
const { activeNode } = storeToRefs(ndvStore);
const settings = useSettingsStore();
const { dirtinessByName } = useNodeDirtiness();
// Composables
@ -201,6 +206,11 @@ const staleData = computed(() => {
if (!node.value) {
return false;
}
if (settings.partialExecutionVersion === 2) {
return dirtinessByName.value[node.value.name] === CanvasNodeDirtiness.PARAMETERS_UPDATED;
}
const updatedAt = workflowsStore.getParametersLastUpdate(node.value.name);
if (!updatedAt || !runTaskData.value) {
return false;
@ -352,7 +362,11 @@ const activatePane = () => {
{{ i18n.baseText(outputPanelEditMode.enabled ? 'ndv.output.edit' : 'ndv.output') }}
</span>
<RunInfo
v-if="hasNodeRun && !pinnedData.hasData.value && runsCount === 1"
v-if="
hasNodeRun &&
!pinnedData.hasData.value &&
(runsCount === 1 || (runsCount > 0 && staleData))
"
v-show="!outputPanelEditMode.enabled"
:task-data="runTaskData"
:has-stale-data="staleData"

View file

@ -29,6 +29,7 @@ import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useDocumentVisibility } from '@/composables/useDocumentVisibility';
import { N8nButton, N8nCallout, N8nNotice } from 'n8n-design-system';
import { isEqual } from 'lodash-es';
type Props = {
parameter: INodeProperties;
@ -354,12 +355,12 @@ async function loadAndSetFieldsToMap(): Promise<void> {
}
return field;
});
state.paramValue = {
...state.paramValue,
schema: newSchema,
};
if (!isEqual(newSchema, state.paramValue.schema)) {
state.paramValue.schema = newSchema;
emitValueChanged();
}
}
}
async function onModeChanged(mode: string): Promise<void> {

View file

@ -3,6 +3,7 @@ import type { ITaskData } from 'n8n-workflow';
import { convertToDisplayDateComponents } from '@/utils/formatters/dateFormatter';
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { N8nInfoTip } from 'n8n-design-system';
const i18n = useI18n();
@ -33,9 +34,9 @@ const runMetadata = computed(() => {
</script>
<template>
<n8n-info-tip
<N8nInfoTip
v-if="hasStaleData"
theme="warning"
theme="warning-light"
type="tooltip"
tooltip-placement="right"
data-test-id="node-run-info-stale"
@ -49,10 +50,10 @@ const runMetadata = computed(() => {
)
"
></span>
</n8n-info-tip>
</N8nInfoTip>
<div v-else-if="runMetadata" :class="$style.tooltipRow">
<n8n-info-tip type="note" :theme="theme" :data-test-id="`node-run-status-${theme}`" />
<n8n-info-tip
<N8nInfoTip type="note" :theme="theme" :data-test-id="`node-run-status-${theme}`" />
<N8nInfoTip
type="tooltip"
theme="info"
:data-test-id="`node-run-info`"
@ -75,7 +76,7 @@ const runMetadata = computed(() => {
}}</n8n-text>
{{ runMetadata.executionTime }} {{ i18n.baseText('runData.ms') }}
</div>
</n8n-info-tip>
</N8nInfoTip>
</div>
</template>

View file

@ -87,7 +87,7 @@ const edgeClasses = computed(() => ({
const edgeLabelStyle = computed(() => ({
transform: `translate(0, ${isConnectorStraight.value ? '-100%' : '0%'})`,
color: edgeColor.value,
color: 'var(--color-text-base)',
}));
const isConnectorStraight = computed(() => renderData.value.isConnectorStraight);

View file

@ -136,7 +136,7 @@ function onClickAdd() {
left: 50%;
transform: translate(-50%, -150%);
font-size: var(--font-size-xs);
color: var(--color-success);
color: var(--color-text-base);
}
</style>

View file

@ -61,6 +61,7 @@ const classes = computed(() => {
[$style.configurable]: renderOptions.value.configurable,
[$style.configuration]: renderOptions.value.configuration,
[$style.trigger]: renderOptions.value.trigger,
[$style.warning]: renderOptions.value.dirtiness !== undefined,
};
});
@ -264,6 +265,10 @@ function onActivate() {
border-color: var(--color-canvas-node-success-border-color, var(--color-success));
}
&.warning {
border-color: var(--color-warning);
}
&.error {
border-color: var(--color-canvas-node-error-border-color, var(--color-danger));
}

View file

@ -1,15 +1,19 @@
<script setup lang="ts">
import { computed, useCssModule } from 'vue';
import { useCanvasNode } from '@/composables/useCanvasNode';
import { CanvasNodeRenderType } from '@/types';
const $style = useCssModule();
const { hasRunData } = useCanvasNode();
const { hasRunData, render } = useCanvasNode();
const classes = computed(() => {
return {
[$style.disabledStrikeThrough]: true,
[$style.success]: hasRunData.value,
[$style.warning]:
render.value.type === CanvasNodeRenderType.Default &&
render.value.options.dirtiness !== undefined,
};
});
</script>
@ -31,4 +35,8 @@ const classes = computed(() => {
.success {
border-color: var(--color-success-light);
}
.warning {
border-color: var(--color-warning-tint-1);
}
</style>

View file

@ -2,6 +2,7 @@ import CanvasNodeStatusIcons from './CanvasNodeStatusIcons.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing';
import { CanvasNodeDirtiness, CanvasNodeRenderType } from '@/types';
const renderComponent = createComponentRenderer(CanvasNodeStatusIcons, {
pinia: createTestingPinia(),
@ -51,4 +52,22 @@ describe('CanvasNodeStatusIcons', () => {
expect(getByTestId('canvas-node-status-success')).toHaveTextContent('15');
});
it('should render correctly for a dirty node that has run successfully', () => {
const { getByTestId } = renderComponent({
global: {
provide: createCanvasNodeProvide({
data: {
runData: { outputMap: {}, iterations: 15, visible: true },
render: {
type: CanvasNodeRenderType.Default,
options: { dirtiness: CanvasNodeDirtiness.PARAMETERS_UPDATED },
},
},
}),
},
});
expect(getByTestId('canvas-node-status-warning')).toBeInTheDocument();
});
});

View file

@ -4,6 +4,8 @@ import TitledList from '@/components/TitledList.vue';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useCanvasNode } from '@/composables/useCanvasNode';
import { useI18n } from '@/composables/useI18n';
import { CanvasNodeDirtiness, CanvasNodeRenderType } from '@/types';
import { N8nTooltip } from 'n8n-design-system';
const nodeHelpers = useNodeHelpers();
const i18n = useI18n();
@ -18,9 +20,13 @@ const {
hasRunData,
runDataIterations,
isDisabled,
render,
} = useCanvasNode();
const hideNodeIssues = computed(() => false); // @TODO Implement this
const dirtiness = computed(() =>
render.value.type === CanvasNodeRenderType.Default ? render.value.options.dirtiness : undefined,
);
</script>
<template>
@ -66,6 +72,23 @@ const hideNodeIssues = computed(() => false); // @TODO Implement this
>
<FontAwesomeIcon icon="sync-alt" spin />
</div>
<div v-else-if="dirtiness !== undefined">
<N8nTooltip :show-after="500" placement="bottom">
<template #content>
{{
i18n.baseText(
dirtiness === CanvasNodeDirtiness.PARAMETERS_UPDATED
? 'node.dirty'
: 'node.subjectToChange',
)
}}
</template>
<div data-test-id="canvas-node-status-warning" :class="[$style.status, $style.warning]">
<FontAwesomeIcon icon="triangle" />
<span v-if="runDataIterations > 1" :class="$style.count"> {{ runDataIterations }}</span>
</div>
</N8nTooltip>
</div>
<div
v-else-if="hasRunData"
data-test-id="canvas-node-status-success"
@ -81,10 +104,10 @@ const hideNodeIssues = computed(() => false); // @TODO Implement this
display: flex;
align-items: center;
gap: var(--spacing-5xs);
font-weight: 600;
}
.runData {
font-weight: 600;
color: var(--color-success);
}
@ -126,4 +149,8 @@ const hideNodeIssues = computed(() => false); // @TODO Implement this
.count {
font-size: var(--font-size-s);
}
.warning {
color: var(--color-warning);
}
</style>

View file

@ -49,6 +49,7 @@ import { sanitizeHtml } from '@/utils/htmlUtils';
import { MarkerType } from '@vue-flow/core';
import { useNodeHelpers } from './useNodeHelpers';
import { getTriggerNodeServiceName } from '@/utils/nodeTypesUtils';
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
export function useCanvasMapping({
nodes,
@ -63,6 +64,7 @@ export function useCanvasMapping({
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const nodeHelpers = useNodeHelpers();
const { dirtinessByName } = useNodeDirtiness();
function createStickyNoteRenderType(node: INodeUi): CanvasNodeStickyNoteRender {
return {
@ -97,6 +99,7 @@ export function useCanvasMapping({
labelSize: nodeOutputLabelSizeById.value[node.id],
},
tooltip: nodeTooltipById.value[node.id],
dirtiness: dirtinessByName.value[node.name],
},
};
}

View file

@ -714,7 +714,9 @@ describe('useCanvasOperations', () => {
expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(id);
expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(id);
expect(historyStore.pushCommandToUndo).toHaveBeenCalledWith(new RemoveNodeCommand(node));
expect(historyStore.pushCommandToUndo).toHaveBeenCalledWith(
new RemoveNodeCommand(node, expect.any(Number)),
);
});
it('should delete node without tracking history', () => {

View file

@ -197,7 +197,9 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
workflowsStore.setNodePositionById(id, newPosition);
if (trackHistory) {
historyStore.pushCommandToUndo(new MoveNodeCommand(node.name, oldPosition, newPosition));
historyStore.pushCommandToUndo(
new MoveNodeCommand(node.name, oldPosition, newPosition, Date.now()),
);
}
}
@ -230,7 +232,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
workflow.renameNode(currentName, newName);
if (trackHistory) {
historyStore.pushCommandToUndo(new RenameNodeCommand(currentName, newName));
historyStore.pushCommandToUndo(new RenameNodeCommand(currentName, newName, Date.now()));
}
// Update also last selected node and execution data
@ -281,7 +283,8 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
if (trackHistory) {
historyStore.pushCommandToUndo(
new AddConnectionCommand([
new AddConnectionCommand(
[
{
node: incomingConnection.node,
type,
@ -292,7 +295,9 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
type,
index: outgoingConnection.index,
},
]),
],
Date.now(),
),
);
}
@ -336,7 +341,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
workflowsStore.removeNodeById(id);
if (trackHistory) {
historyStore.pushCommandToUndo(new RemoveNodeCommand(node));
historyStore.pushCommandToUndo(new RemoveNodeCommand(node, Date.now()));
if (trackBulk) {
historyStore.stopRecordingUndo();
@ -614,7 +619,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
workflowsStore.addNode(nodeData);
if (options.trackHistory) {
historyStore.pushCommandToUndo(new AddNodeCommand(nodeData));
historyStore.pushCommandToUndo(new AddNodeCommand(nodeData, Date.now()));
}
if (!options.isAutoAdd) {
@ -1099,6 +1104,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
historyStore.pushCommandToUndo(
new AddConnectionCommand(
mapCanvasConnectionToLegacyConnection(sourceNode, targetNode, connection),
Date.now(),
),
);
}
@ -1229,7 +1235,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
});
if (trackHistory) {
historyStore.pushCommandToUndo(new RemoveConnectionCommand(mappedConnection));
historyStore.pushCommandToUndo(new RemoveConnectionCommand(mappedConnection, Date.now()));
if (trackBulk) {
historyStore.stopRecordingUndo();

View file

@ -32,13 +32,16 @@ export function useHistoryHelper(activeRoute: RouteLocationNormalizedLoaded) {
if (!command) {
return;
}
const timestamp = Date.now();
if (command instanceof BulkCommand) {
historyStore.bulkInProgress = true;
const commands = command.commands;
const reverseCommands: Command[] = [];
for (let i = commands.length - 1; i >= 0; i--) {
await commands[i].revert();
reverseCommands.push(commands[i].getReverseCommand());
reverseCommands.push(commands[i].getReverseCommand(timestamp));
}
historyStore.pushUndoableToRedo(new BulkCommand(reverseCommands));
await nextTick();
@ -46,7 +49,7 @@ export function useHistoryHelper(activeRoute: RouteLocationNormalizedLoaded) {
}
if (command instanceof Command) {
await command.revert();
historyStore.pushUndoableToRedo(command.getReverseCommand());
historyStore.pushUndoableToRedo(command.getReverseCommand(timestamp));
uiStore.stateIsDirty = true;
}
trackCommand(command, 'undo');
@ -61,13 +64,16 @@ export function useHistoryHelper(activeRoute: RouteLocationNormalizedLoaded) {
if (!command) {
return;
}
const timestamp = Date.now();
if (command instanceof BulkCommand) {
historyStore.bulkInProgress = true;
const commands = command.commands;
const reverseCommands = [];
for (let i = commands.length - 1; i >= 0; i--) {
await commands[i].revert();
reverseCommands.push(commands[i].getReverseCommand());
reverseCommands.push(commands[i].getReverseCommand(timestamp));
}
historyStore.pushBulkCommandToUndo(new BulkCommand(reverseCommands), false);
await nextTick();
@ -75,7 +81,7 @@ export function useHistoryHelper(activeRoute: RouteLocationNormalizedLoaded) {
}
if (command instanceof Command) {
await command.revert();
historyStore.pushCommandToUndo(command.getReverseCommand(), false);
historyStore.pushCommandToUndo(command.getReverseCommand(timestamp), false);
uiStore.stateIsDirty = true;
}
trackCommand(command, 'redo');

View file

@ -0,0 +1,464 @@
/* eslint-disable n8n-local-rules/no-unneeded-backticks */
import { createTestNode, createTestWorkflow, defaultNodeDescriptions } from '@/__tests__/mocks';
import { createComponentRenderer } from '@/__tests__/render';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { useHistoryHelper } from '@/composables/useHistoryHelper';
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
import { type INodeUi } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { CanvasNodeDirtiness } from '@/types';
import { type FrontendSettings } from '@n8n/api-types';
import { createTestingPinia } from '@pinia/testing';
import { NodeConnectionType, type IConnections, type IRunData } from 'n8n-workflow';
import { defineComponent } from 'vue';
import {
createRouter,
createWebHistory,
useRouter,
type RouteLocationNormalizedLoaded,
} from 'vue-router';
describe(useNodeDirtiness, () => {
let nodeTypeStore: ReturnType<typeof useNodeTypesStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
let historyHelper: ReturnType<typeof useHistoryHelper>;
let canvasOperations: ReturnType<typeof useCanvasOperations>;
let uiStore: ReturnType<typeof useUIStore>;
const NODE_RUN_AT = new Date('2025-01-01T00:00:01');
const WORKFLOW_UPDATED_AT = new Date('2025-01-01T00:00:10');
beforeEach(() => {
vi.useFakeTimers();
const TestComponent = defineComponent({
setup() {
nodeTypeStore = useNodeTypesStore();
workflowsStore = useWorkflowsStore();
settingsStore = useSettingsStore();
historyHelper = useHistoryHelper({} as RouteLocationNormalizedLoaded);
canvasOperations = useCanvasOperations({ router: useRouter() });
uiStore = useUIStore();
nodeTypeStore.setNodeTypes(defaultNodeDescriptions);
// Enable new partial execution
settingsStore.settings = {
partialExecution: { version: 2 },
} as FrontendSettings;
},
template: '<div />',
});
createComponentRenderer(TestComponent, {
global: {
plugins: [
createTestingPinia({ stubActions: false, fakeApp: true }),
createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: TestComponent }],
}),
],
},
})();
});
it('should be an empty object if no change has been made to the workflow', () => {
setupTestWorkflow('a🚨✅, b✅, c✅');
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
});
describe('injecting a node', () => {
it('should mark a node as dirty if a new node is injected as its parent', async () => {
useNodeTypesStore().setNodeTypes(defaultNodeDescriptions);
setupTestWorkflow('a🚨✅ -> b✅');
uiStore.lastInteractedWithNodeConnection = {
source: workflowsStore.nodesByName.a.id,
target: workflowsStore.nodesByName.b.id,
};
uiStore.lastInteractedWithNodeId = workflowsStore.nodesByName.a.id;
uiStore.lastInteractedWithNodeHandle = 'outputs/main/0';
await canvasOperations.addNodes([createTestNode({ name: 'c' })], { trackHistory: true });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
});
describe('deleting a node', () => {
it('should mark a node as dirty if a parent node is replaced by removing a node', async () => {
useNodeTypesStore().setNodeTypes(defaultNodeDescriptions);
setupTestWorkflow('a🚨✅ -> b✅ -> c✅');
canvasOperations.deleteNodes([workflowsStore.nodesByName.b.id], { trackHistory: true }); // 'a' becomes new parent of 'c'
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
it('should mark a node as dirty if a parent node get removed', async () => {
useNodeTypesStore().setNodeTypes(defaultNodeDescriptions);
setupTestWorkflow('a🚨✅ -> b✅ -> c✅');
canvasOperations.deleteNodes([workflowsStore.nodesByName.a.id], { trackHistory: true }); // 'b' has no parent node anymore
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
});
describe('updating node parameters', () => {
it('should mark a node as dirty if its parameter has changed', () => {
setupTestWorkflow('a🚨✅, b✅, c✅');
canvasOperations.setNodeParameters(workflowsStore.nodesByName.b.id, { foo: 1 });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
b: CanvasNodeDirtiness.PARAMETERS_UPDATED,
});
});
it('should clear dirtiness if a dirty node gets new run data', () => {
useNodeTypesStore().setNodeTypes(defaultNodeDescriptions);
setupTestWorkflow('a🚨✅ -> b✅ -> c✅');
canvasOperations.setNodeParameters(workflowsStore.nodesByName.b.id, { foo: 1 });
const runAt = new Date(+WORKFLOW_UPDATED_AT + 1000);
workflowsStore.setWorkflowExecutionData({
id: workflowsStore.workflow.id,
finished: true,
mode: 'manual',
status: 'success',
workflowData: workflowsStore.workflow,
startedAt: runAt,
createdAt: runAt,
data: {
resultData: {
runData: {
b: [
{
startTime: +runAt,
executionTime: 0,
executionStatus: 'success',
source: [],
},
],
},
},
},
});
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
});
it("should not update dirtiness if the node hasn't run yet", () => {
setupTestWorkflow('a🚨✅, b, c✅');
canvasOperations.setNodeParameters(workflowsStore.nodesByName.b.id, { foo: 1 });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
});
it('should not update dirtiness when the notes field is updated', () => {
setupTestWorkflow('a🚨✅ -> b✅ -> c✅');
workflowsStore.setNodeValue({ key: 'notes', name: 'b', value: 'test' });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
});
});
describe('adding a connection', () => {
it('should mark a node as dirty if a new incoming connection is added', () => {
useNodeTypesStore().setNodeTypes(defaultNodeDescriptions);
setupTestWorkflow('a🚨✅ -> b✅ -> c✅');
canvasOperations.createConnection(
{ source: workflowsStore.nodesByName.a.id, target: workflowsStore.nodesByName.c.id },
{ trackHistory: true },
);
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
});
describe('removing a connection', () => {
it('should not change dirtiness', () => {
useNodeTypesStore().setNodeTypes(defaultNodeDescriptions);
setupTestWorkflow('a🚨✅ -> b✅ -> c✅');
canvasOperations.deleteConnection(
{ source: workflowsStore.nodesByName.a.id, target: workflowsStore.nodesByName.b.id },
{ trackHistory: true },
);
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
});
});
describe('enabling/disabling nodes', () => {
it('should mark downstream nodes dirty if the node is set to disabled', () => {
setupTestWorkflow('a🚨✅ -> b✅ -> c✅ -> d✅');
canvasOperations.toggleNodesDisabled([workflowsStore.nodesByName.b.id], {
trackHistory: true,
});
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
it('should not mark anything dirty if a disabled node is set to enabled', () => {
setupTestWorkflow('a🚨✅ -> b🚫 -> c✅ -> d✅');
canvasOperations.toggleNodesDisabled([workflowsStore.nodesByName.b.id], {
trackHistory: true,
});
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
});
it('should restore original dirtiness after undoing a command', async () => {
setupTestWorkflow('a🚨✅ -> b✅ -> c✅ -> d✅');
canvasOperations.toggleNodesDisabled([workflowsStore.nodesByName.b.id], {
trackHistory: true,
});
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
await historyHelper.undo();
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
});
});
describe('pinned data', () => {
it('should not change dirtiness when data is pinned', async () => {
setupTestWorkflow('a🚨✅ -> b✅ -> c✅');
canvasOperations.toggleNodesPinned([workflowsStore.nodesByName.b.id], 'pin-icon-click', {
trackHistory: true,
});
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
});
it('should update dirtiness when pinned data is removed from a node with run data', async () => {
setupTestWorkflow('a🚨✅ -> b✅📌 -> c✅, b -> d, b -> e✅ -> f✅');
canvasOperations.toggleNodesPinned([workflowsStore.nodesByName.b.id], 'pin-icon-click', {
trackHistory: true,
});
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
b: CanvasNodeDirtiness.PINNED_DATA_UPDATED,
});
});
it('should update dirtiness when an existing pinned data of an incoming node is updated', async () => {
setupTestWorkflow('a🚨✅ -> b✅📌 -> c✅, b -> d, b -> e✅ -> f✅');
workflowsStore.pinData({ node: workflowsStore.nodesByName.b, data: [{ json: {} }] });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
// 'd' is not marked as pinned-data-updated because it has no run data.
c: CanvasNodeDirtiness.PINNED_DATA_UPDATED,
e: CanvasNodeDirtiness.PINNED_DATA_UPDATED,
});
});
});
describe('sub-nodes', () => {
it('should mark its parent nodes with run data as dirty when parameters of a sub node has changed', () => {
setupTestWorkflow('a🚨✅ -> b✅ -> c✅, d🧠 -> b, e🧠 -> f✅🧠 -> b');
canvasOperations.setNodeParameters(workflowsStore.nodesByName.e.id, { foo: 1 });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
// 'e' itself is not marked as parameters-updated, because it has no run data.
f: CanvasNodeDirtiness.UPSTREAM_DIRTY,
b: CanvasNodeDirtiness.UPSTREAM_DIRTY,
});
});
it('should change dirtiness if a disabled sub node is set to enabled', () => {
setupTestWorkflow('a🚨✅ -> b✅ -> c✅, d🧠🚫 -> b');
canvasOperations.toggleNodesDisabled([workflowsStore.nodesByName.d.id], {
trackHistory: true,
});
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
it('should change dirtiness if a sub node is removed', () => {
setupTestWorkflow('a🚨✅ -> b✅ -> c✅, d🧠 -> b');
canvasOperations.deleteNodes([workflowsStore.nodesByName.d.id], { trackHistory: true });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
});
describe('workflow with a loop', () => {
it('should change the dirtiness of the first node in a loop when one of nodes in the loop becomes dirty', () => {
setupTestWorkflow('a🚨✅ -> b✅ -> c✅ -> d✅ -> e✅ -> f✅ -> c✅');
canvasOperations.setNodeParameters(workflowsStore.nodesByName.e.id, { foo: 1 });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
c: CanvasNodeDirtiness.UPSTREAM_DIRTY,
e: CanvasNodeDirtiness.PARAMETERS_UPDATED,
});
});
it('should not choose a node as the first node in a loop if all nodes in the loop have incoming connections', () => {
setupTestWorkflow('a🚨✅ -> b✅ -> c✅, d✅ -> e✅ -> d✅, d -> b');
canvasOperations.setNodeParameters(workflowsStore.nodesByName.c.id, { foo: 1 });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
c: CanvasNodeDirtiness.PARAMETERS_UPDATED,
});
});
});
describe('renaming a node', () => {
it.todo('should preserve the dirtiness', async () => {
useNodeTypesStore().setNodeTypes(defaultNodeDescriptions);
setupTestWorkflow('a🚨✅ -> b✅ -> c✅');
canvasOperations.deleteNodes([workflowsStore.nodesByName.b.id], { trackHistory: true }); // 'a' becomes new parent of 'c'
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
await canvasOperations.renameNode('c', 'd', { trackHistory: true });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
d: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
});
/**
* Setup test data in the workflow store using given diagram.
*
* [Symbols]
* - 🚨: Trigger node
* - : Node with run data
* - 🚫: Disabled node
* - 📌: Node with pinned data
* - 🧠: A sub node
*/
function setupTestWorkflow(diagram: string) {
const nodeNamesWithPinnedData = new Set<string>();
const nodes: Record<string, INodeUi> = {};
const connections: IConnections = {};
const runData: IRunData = {};
for (const subGraph of diagram.split(/\n|,/).filter((line) => line.trim() !== '')) {
const elements = subGraph.split(/(->)/).map((s) => s.trim());
elements.forEach((element, i, arr) => {
if (element === '->') {
const from = arr[i - 1].slice(0, 1);
const to = arr[i + 1].slice(0, 1);
const type = arr[i - 1].includes('🧠')
? NodeConnectionType.AiAgent
: NodeConnectionType.Main;
const conns = connections[from]?.[type] ?? [];
const conn = conns[0] ?? [];
connections[from] = {
...connections[from],
[type]: [[...conn, { node: to, type, index: conn.length }], ...conns.slice(1)],
};
return;
}
const [name, ...attributes] = element.trim();
nodes[name] =
nodes[name] ??
createTestNode({
name,
disabled: attributes.includes('🚫'),
type: attributes.includes('🚨') ? MANUAL_TRIGGER_NODE_TYPE : SET_NODE_TYPE,
});
if (attributes.includes('✅')) {
runData[name] = [
{
startTime: +NODE_RUN_AT,
executionTime: 0,
executionStatus: 'success',
source: [],
},
];
}
if (attributes.includes('📌')) {
nodeNamesWithPinnedData.add(name);
}
});
}
const workflow = createTestWorkflow({ nodes: Object.values(nodes), connections });
workflowsStore.setNodes(workflow.nodes);
workflowsStore.setConnections(workflow.connections);
for (const name of nodeNamesWithPinnedData) {
workflowsStore.pinData({
node: workflowsStore.nodesByName[name],
data: [{ json: {} }],
});
}
workflowsStore.setWorkflowExecutionData({
id: workflow.id,
finished: true,
mode: 'manual',
status: 'success',
workflowData: workflow,
startedAt: NODE_RUN_AT,
createdAt: NODE_RUN_AT,
data: { resultData: { runData } },
});
// prepare for making changes to the workflow
vi.setSystemTime(WORKFLOW_UPDATED_AT);
}
});

View file

@ -0,0 +1,309 @@
import {
AddConnectionCommand,
AddNodeCommand,
BulkCommand,
EnableNodeToggleCommand,
RemoveConnectionCommand,
RemoveNodeCommand,
type Undoable,
} from '@/models/history';
import { useHistoryStore } from '@/stores/history.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { CanvasNodeDirtiness, type CanvasNodeDirtinessType } from '@/types';
import { type INodeConnections, NodeConnectionType } from 'n8n-workflow';
import { computed } from 'vue';
/**
* Does the command make the given node dirty?
*/
function shouldCommandMarkDirty(
command: Undoable,
nodeName: string,
siblingCommands: Undoable[],
getIncomingConnections: (nodeName: string) => INodeConnections,
getOutgoingConnectors: (nodeName: string) => INodeConnections,
): boolean {
if (command instanceof BulkCommand) {
return command.commands.some((cmd) =>
shouldCommandMarkDirty(
cmd,
nodeName,
command.commands,
getIncomingConnections,
getOutgoingConnectors,
),
);
}
if (command instanceof AddConnectionCommand) {
return command.connectionData[1]?.node === nodeName;
}
if (command instanceof RemoveConnectionCommand) {
const [from, to] = command.connectionData;
if (to.node !== nodeName) {
return false;
}
// the connection was removed along with its source node
return siblingCommands.some(
(sibling) => sibling instanceof RemoveNodeCommand && sibling.node.name === from.node,
);
}
const incomingNodes = Object.values(getIncomingConnections(nodeName))
.flat()
.flat()
.filter((connection) => connection !== null)
.map((connection) => connection.node);
if (command instanceof AddNodeCommand) {
return incomingNodes.includes(command.node.name);
}
if (command instanceof EnableNodeToggleCommand) {
return (
incomingNodes.includes(command.nodeName) &&
(command.newState ||
Object.keys(getOutgoingConnectors(command.nodeName)).some(
(type) => (type as NodeConnectionType) !== NodeConnectionType.Main,
))
);
}
return false;
}
/**
* If given node is part of a loop, returns the set of nodes that forms the loop, otherwise returns undefined.
*/
function findLoop(
nodeName: string,
visited: string[],
getIncomingConnections: (nodeName: string) => INodeConnections,
): string[] | undefined {
const index = visited.indexOf(nodeName);
if (index >= 0) {
return visited.slice(index);
}
const newVisited = [...visited, nodeName];
for (const [type, typeConnections] of Object.entries(getIncomingConnections(nodeName))) {
if ((type as NodeConnectionType) !== NodeConnectionType.Main) {
continue;
}
for (const connections of typeConnections) {
for (const { node } of connections ?? []) {
const loop = findLoop(node, newVisited, getIncomingConnections);
if (loop) {
return loop;
}
}
}
}
return undefined;
}
/**
* Determines the subgraph that is affected by changes made after the last (partial) execution
*/
export function useNodeDirtiness() {
const historyStore = useHistoryStore();
const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore();
function getParentSubNodes(nodeName: string) {
return Object.entries(workflowsStore.incomingConnectionsByNodeName(nodeName))
.filter(([type]) => (type as NodeConnectionType) !== NodeConnectionType.Main)
.flatMap(([, typeConnections]) => typeConnections.flat().filter((conn) => conn !== null));
}
function getDirtinessByParametersUpdate(
nodeName: string,
after: number,
): CanvasNodeDirtinessType | undefined {
if ((workflowsStore.getParametersLastUpdate(nodeName) ?? 0) > after) {
return CanvasNodeDirtiness.PARAMETERS_UPDATED;
}
for (const connection of getParentSubNodes(nodeName)) {
if (getDirtinessByParametersUpdate(connection.node, after) !== undefined) {
return CanvasNodeDirtiness.UPSTREAM_DIRTY;
}
}
return undefined;
}
function getDirtinessByConnectionsUpdate(
nodeName: string,
after: number,
): CanvasNodeDirtinessType | undefined {
for (let i = historyStore.undoStack.length - 1; i >= 0; i--) {
const command = historyStore.undoStack[i];
if (command.getTimestamp() < after) {
break;
}
if (
shouldCommandMarkDirty(
command,
nodeName,
[],
workflowsStore.incomingConnectionsByNodeName,
workflowsStore.outgoingConnectionsByNodeName,
)
) {
return CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED;
}
}
for (const connection of getParentSubNodes(nodeName)) {
if (getDirtinessByConnectionsUpdate(connection.node, after) !== undefined) {
return CanvasNodeDirtiness.UPSTREAM_DIRTY;
}
}
return undefined;
}
/**
* Depth of node is defined as the minimum distance (number of connections) from the trigger node
*/
const depthByName = computed(() => {
const depth: Record<string, number> = {};
function setDepthRecursively(nodeName: string, current: number, visited: Set<string>) {
if (visited.has(nodeName)) {
return;
}
const myVisited = new Set<string>(visited);
myVisited.add(nodeName);
for (const [type, typeConnections] of Object.entries(
workflowsStore.outgoingConnectionsByNodeName(nodeName),
)) {
if ((type as NodeConnectionType) !== NodeConnectionType.Main) {
continue;
}
for (const connections of typeConnections) {
for (const { node } of connections ?? []) {
if (!depth[node] || depth[node] > current) {
depth[node] = current;
}
setDepthRecursively(node, current + 1, myVisited);
}
}
}
}
for (const startNode of workflowsStore.allNodes) {
const hasIncomingNode =
Object.keys(workflowsStore.incomingConnectionsByNodeName(startNode.name)).length > 0;
if (hasIncomingNode) {
continue;
}
depth[startNode.name] = 0;
setDepthRecursively(startNode.name, 1, new Set());
}
return depth;
});
const dirtinessByName = computed(() => {
// Do not highlight dirtiness if new partial execution is not enabled
if (settingsStore.partialExecutionVersion === 1) {
return {};
}
const dirtiness: Record<string, CanvasNodeDirtinessType | undefined> = {};
const runDataByNode = workflowsStore.getWorkflowRunData ?? {};
function setDirtiness(nodeName: string, value: CanvasNodeDirtinessType) {
dirtiness[nodeName] = dirtiness[nodeName] ?? value;
const loop = findLoop(nodeName, [], workflowsStore.incomingConnectionsByNodeName);
if (!loop) {
return;
}
const loopEntryNodeName = [...loop].sort(
(a, b) =>
(depthByName.value[a] ?? Number.MAX_SAFE_INTEGER) -
(depthByName.value[b] ?? Number.MAX_SAFE_INTEGER),
)?.[0];
if (loopEntryNodeName && depthByName.value[loopEntryNodeName]) {
// If a node in a loop becomes dirty, the first node in the loop should also be dirty
dirtiness[loopEntryNodeName] =
dirtiness[loopEntryNodeName] ?? CanvasNodeDirtiness.UPSTREAM_DIRTY;
}
}
for (const [nodeName, runData] of Object.entries(runDataByNode)) {
const runAt = runData[0]?.startTime ?? 0;
if (!runAt) {
continue;
}
const parameterUpdate = getDirtinessByParametersUpdate(nodeName, runAt);
if (parameterUpdate) {
setDirtiness(nodeName, parameterUpdate);
continue;
}
const connectionUpdate = getDirtinessByConnectionsUpdate(nodeName, runAt);
if (connectionUpdate) {
setDirtiness(nodeName, connectionUpdate);
continue;
}
const hasInputPinnedDataChanged = Object.values(
workflowsStore.incomingConnectionsByNodeName(nodeName),
)
.flat()
.flat()
.filter((connection) => connection !== null)
.some((connection) => {
const pinnedDataLastUpdatedAt =
workflowsStore.getPinnedDataLastUpdate(connection.node) ?? 0;
return pinnedDataLastUpdatedAt > runAt;
});
if (hasInputPinnedDataChanged) {
setDirtiness(nodeName, CanvasNodeDirtiness.PINNED_DATA_UPDATED);
continue;
}
const pinnedDataLastRemovedAt = workflowsStore.getPinnedDataLastRemovedAt(nodeName) ?? 0;
if (pinnedDataLastRemovedAt > runAt) {
setDirtiness(nodeName, CanvasNodeDirtiness.PINNED_DATA_UPDATED);
continue;
}
}
return dirtiness;
});
return { dirtinessByName };
}

View file

@ -642,7 +642,7 @@ export function useNodeHelpers() {
}
// Toggle disabled flag
const updateInformation = {
const updateInformation: INodeUpdatePropertiesInformation = {
name: node.name,
properties: {
disabled: newDisabledState,
@ -662,7 +662,12 @@ export function useNodeHelpers() {
updateNodesInputIssues();
if (trackHistory) {
historyStore.pushCommandToUndo(
new EnableNodeToggleCommand(node.name, node.disabled === true, newDisabledState),
new EnableNodeToggleCommand(
node.name,
node.disabled === true,
newDisabledState,
Date.now(),
),
);
}
}

View file

@ -9,6 +9,8 @@ import {
type Workflow,
type IExecuteData,
type ITaskData,
NodeConnectionType,
type INodeConnections,
} from 'n8n-workflow';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
@ -21,9 +23,11 @@ import { useI18n } from '@/composables/useI18n';
import { captor, mock } from 'vitest-mock-extended';
import { useSettingsStore } from '@/stores/settings.store';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { createTestNode } from '@/__tests__/mocks';
vi.mock('@/stores/workflows.store', () => ({
useWorkflowsStore: vi.fn().mockReturnValue({
allNodes: [],
runWorkflow: vi.fn(),
subWorkflowExecutionError: null,
getWorkflowRunData: null,
@ -37,6 +41,10 @@ vi.mock('@/stores/workflows.store', () => ({
nodeIssuesExit: vi.fn(),
checkIfNodeHasChatParent: vi.fn(),
getParametersLastUpdate: vi.fn(),
getPinnedDataLastUpdate: vi.fn(),
getPinnedDataLastRemovedAt: vi.fn(),
incomingConnectionsByNodeName: vi.fn(),
outgoingConnectionsByNodeName: vi.fn(),
}),
}));
@ -326,6 +334,20 @@ describe('useRunWorkflow({ router })', () => {
const composable = useRunWorkflow({ router });
const parentName = 'When clicking';
const executeName = 'Code';
vi.mocked(workflowsStore).allNodes = [
createTestNode({ name: parentName }),
createTestNode({ name: executeName }),
];
vi.mocked(workflowsStore).outgoingConnectionsByNodeName.mockImplementation((nodeName) =>
nodeName === parentName
? { main: [[{ node: executeName, type: NodeConnectionType.Main, index: 0 }]] }
: ({} as INodeConnections),
);
vi.mocked(workflowsStore).incomingConnectionsByNodeName.mockImplementation((nodeName) =>
nodeName === executeName
? { main: [[{ node: parentName, type: NodeConnectionType.Main, index: 0 }]] }
: ({} as INodeConnections),
);
vi.mocked(workflowsStore).getWorkflowRunData = {
[parentName]: [
{

View file

@ -39,25 +39,7 @@ import { useExecutionsStore } from '@/stores/executions.store';
import { useTelemetry } from './useTelemetry';
import { useSettingsStore } from '@/stores/settings.store';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
const getDirtyNodeNames = (
runData: IRunData,
getParametersLastUpdate: (nodeName: string) => number | undefined,
): string[] | undefined => {
const dirtyNodeNames = Object.entries(runData).reduce<string[]>((acc, [nodeName, tasks]) => {
if (!tasks.length) return acc;
const updatedAt = getParametersLastUpdate(nodeName) ?? 0;
if (updatedAt > tasks[0].startTime) {
acc.push(nodeName);
}
return acc;
}, []);
return dirtyNodeNames.length ? dirtyNodeNames : undefined;
};
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof useRouter> }) {
const nodeHelpers = useNodeHelpers();
@ -73,6 +55,8 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const executionsStore = useExecutionsStore();
const { dirtinessByName } = useNodeDirtiness();
// Starts to execute a workflow on server
async function runWorkflowApi(runData: IStartRunData): Promise<IExecutionPushResponse> {
if (!pushConnectionStore.isConnected) {
@ -229,6 +213,11 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
}
}
// partial executions must have a destination node
const isPartialExecution = options.destinationNode !== undefined;
const version = settingsStore.partialExecutionVersion;
// TODO: this will be redundant once we cleanup the partial execution v1
const startNodes: StartNodeData[] = startNodeNames.map((name) => {
// Find for each start node the source data
let sourceData = get(runData, [name, 0, 'source', 0], null);
@ -267,10 +256,6 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
return undefined;
}
// partial executions must have a destination node
const isPartialExecution = options.destinationNode !== undefined;
const settingsStore = useSettingsStore();
const version = settingsStore.partialExecutionVersion;
const startRunData: IStartRunData = {
workflowData,
// With the new partial execution version the backend decides what run
@ -293,10 +278,11 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
}
if (startRunData.runData) {
startRunData.dirtyNodeNames = getDirtyNodeNames(
startRunData.runData,
workflowsStore.getParametersLastUpdate,
const nodeNames = Object.entries(dirtinessByName.value).flatMap(([nodeName, dirtiness]) =>
dirtiness ? [nodeName] : [],
);
startRunData.dirtyNodeNames = nodeNames.length > 0 ? nodeNames : undefined;
}
// Init the execution data to represent the start of the execution

View file

@ -22,18 +22,27 @@ export const enum COMMANDS {
const CANVAS_ACTION_TIMEOUT = 10;
export const historyBus = createEventBus();
export abstract class Undoable {}
export abstract class Undoable {
abstract getTimestamp(): number;
}
export abstract class Command extends Undoable {
readonly name: string;
constructor(name: string) {
readonly timestamp: number;
constructor(name: string, timestamp: number) {
super();
this.name = name;
this.timestamp = timestamp;
}
abstract getReverseCommand(): Command;
abstract getReverseCommand(timestamp: number): Command;
abstract isEqualTo(anotherCommand: Command): boolean;
abstract revert(): Promise<void>;
getTimestamp(): number {
return this.timestamp;
}
}
export class BulkCommand extends Undoable {
@ -43,6 +52,10 @@ export class BulkCommand extends Undoable {
super();
this.commands = commands;
}
getTimestamp(): number {
return Math.max(0, ...this.commands.map((command) => command.timestamp));
}
}
export class MoveNodeCommand extends Command {
@ -52,15 +65,20 @@ export class MoveNodeCommand extends Command {
newPosition: XYPosition;
constructor(nodeName: string, oldPosition: XYPosition, newPosition: XYPosition) {
super(COMMANDS.MOVE_NODE);
constructor(
nodeName: string,
oldPosition: XYPosition,
newPosition: XYPosition,
timestamp: number,
) {
super(COMMANDS.MOVE_NODE, timestamp);
this.nodeName = nodeName;
this.newPosition = newPosition;
this.oldPosition = oldPosition;
}
getReverseCommand(): Command {
return new MoveNodeCommand(this.nodeName, this.newPosition, this.oldPosition);
getReverseCommand(timestamp: number): Command {
return new MoveNodeCommand(this.nodeName, this.newPosition, this.oldPosition, timestamp);
}
isEqualTo(anotherCommand: Command): boolean {
@ -88,13 +106,13 @@ export class MoveNodeCommand extends Command {
export class AddNodeCommand extends Command {
node: INodeUi;
constructor(node: INodeUi) {
super(COMMANDS.ADD_NODE);
constructor(node: INodeUi, timestamp: number) {
super(COMMANDS.ADD_NODE, timestamp);
this.node = node;
}
getReverseCommand(): Command {
return new RemoveNodeCommand(this.node);
getReverseCommand(timestamp: number): Command {
return new RemoveNodeCommand(this.node, timestamp);
}
isEqualTo(anotherCommand: Command): boolean {
@ -112,13 +130,13 @@ export class AddNodeCommand extends Command {
export class RemoveNodeCommand extends Command {
node: INodeUi;
constructor(node: INodeUi) {
super(COMMANDS.REMOVE_NODE);
constructor(node: INodeUi, timestamp: number) {
super(COMMANDS.REMOVE_NODE, timestamp);
this.node = node;
}
getReverseCommand(): Command {
return new AddNodeCommand(this.node);
getReverseCommand(timestamp: number): Command {
return new AddNodeCommand(this.node, timestamp);
}
isEqualTo(anotherCommand: Command): boolean {
@ -136,13 +154,13 @@ export class RemoveNodeCommand extends Command {
export class AddConnectionCommand extends Command {
connectionData: [IConnection, IConnection];
constructor(connectionData: [IConnection, IConnection]) {
super(COMMANDS.ADD_CONNECTION);
constructor(connectionData: [IConnection, IConnection], timestamp: number) {
super(COMMANDS.ADD_CONNECTION, timestamp);
this.connectionData = connectionData;
}
getReverseCommand(): Command {
return new RemoveConnectionCommand(this.connectionData);
getReverseCommand(timestamp: number): Command {
return new RemoveConnectionCommand(this.connectionData, timestamp);
}
isEqualTo(anotherCommand: Command): boolean {
@ -166,13 +184,13 @@ export class AddConnectionCommand extends Command {
export class RemoveConnectionCommand extends Command {
connectionData: [IConnection, IConnection];
constructor(connectionData: [IConnection, IConnection]) {
super(COMMANDS.REMOVE_CONNECTION);
constructor(connectionData: [IConnection, IConnection], timestamp: number) {
super(COMMANDS.REMOVE_CONNECTION, timestamp);
this.connectionData = connectionData;
}
getReverseCommand(): Command {
return new AddConnectionCommand(this.connectionData);
getReverseCommand(timestamp: number): Command {
return new AddConnectionCommand(this.connectionData, timestamp);
}
isEqualTo(anotherCommand: Command): boolean {
@ -202,15 +220,15 @@ export class EnableNodeToggleCommand extends Command {
newState: boolean;
constructor(nodeName: string, oldState: boolean, newState: boolean) {
super(COMMANDS.ENABLE_NODE_TOGGLE);
constructor(nodeName: string, oldState: boolean, newState: boolean, timestamp: number) {
super(COMMANDS.ENABLE_NODE_TOGGLE, timestamp);
this.nodeName = nodeName;
this.newState = newState;
this.oldState = oldState;
}
getReverseCommand(): Command {
return new EnableNodeToggleCommand(this.nodeName, this.newState, this.oldState);
getReverseCommand(timestamp: number): Command {
return new EnableNodeToggleCommand(this.nodeName, this.newState, this.oldState, timestamp);
}
isEqualTo(anotherCommand: Command): boolean {
@ -235,14 +253,14 @@ export class RenameNodeCommand extends Command {
newName: string;
constructor(currentName: string, newName: string) {
super(COMMANDS.RENAME_NODE);
constructor(currentName: string, newName: string, timestamp: number) {
super(COMMANDS.RENAME_NODE, timestamp);
this.currentName = currentName;
this.newName = newName;
}
getReverseCommand(): Command {
return new RenameNodeCommand(this.newName, this.currentName);
getReverseCommand(timestamp: number): Command {
return new RenameNodeCommand(this.newName, this.currentName, timestamp);
}
isEqualTo(anotherCommand: Command): boolean {

View file

@ -982,7 +982,8 @@
"ndv.input.noOutputData": "No data",
"ndv.input.noOutputData.executePrevious": "Execute previous nodes",
"ndv.input.noOutputData.title": "No input data yet",
"ndv.input.noOutputData.hint": "(From the earliest node that has no output data yet)",
"ndv.input.noOutputData.hint": "(From the earliest node that needs it {info} )",
"ndv.input.noOutputData.hint.tooltip": "From the earliest node which is unexecuted, or is executed but has since been changed",
"ndv.input.noOutputData.schemaPreviewHint": "switch to {schema} to use the schema preview",
"ndv.input.noOutputData.or": "or",
"ndv.input.executingPrevious": "Executing previous nodes...",
@ -1063,6 +1064,8 @@
"node.delete": "Delete",
"node.add": "Add",
"node.issues": "Issues",
"node.dirty": "Node configuration changed. Output data may change when this node is run again",
"node.subjectToChange": "Because of changes in the workflow, output data may change when this node is run again",
"node.nodeIsExecuting": "Node is executing",
"node.nodeIsWaitingTill": "Node is waiting until {date} {time}",
"node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall": "The node is waiting for an incoming webhook call (indefinitely)",

View file

@ -48,3 +48,15 @@ export const faRefresh: IconDefinition = {
'M8.67188 3.64062C7.94531 2.96094 6.98438 2.5625 5.97656 2.5625C4.17188 2.58594 2.60156 3.82812 2.17969 5.53906C2.13281 5.67969 2.01562 5.75 1.89844 5.75H0.5625C0.375 5.75 0.234375 5.60938 0.28125 5.42188C0.773438 2.72656 3.14062 0.6875 6 0.6875C7.54688 0.6875 8.95312 1.32031 10.0078 2.30469L10.8516 1.46094C11.2031 1.10938 11.8125 1.36719 11.8125 1.85938V5C11.8125 5.32812 11.5547 5.5625 11.25 5.5625H8.08594C7.59375 5.5625 7.33594 4.97656 7.6875 4.625L8.67188 3.64062ZM0.75 7.4375H3.89062C4.38281 7.4375 4.64062 8.04688 4.28906 8.39844L3.30469 9.38281C4.03125 10.0625 4.99219 10.4609 6 10.4609C7.80469 10.4375 9.375 9.19531 9.79688 7.48438C9.84375 7.34375 9.96094 7.27344 10.0781 7.27344H11.4141C11.6016 7.27344 11.7422 7.41406 11.6953 7.60156C11.2031 10.2969 8.83594 12.3125 6 12.3125C4.42969 12.3125 3.02344 11.7031 1.96875 10.7188L1.125 11.5625C0.773438 11.9141 0.1875 11.6562 0.1875 11.1641V8C0.1875 7.69531 0.421875 7.4375 0.75 7.4375Z',
],
};
export const faTriangle: IconDefinition = {
prefix: 'fas',
iconName: 'triangle',
icon: [
512,
512,
[],
'',
'M214.433 56C232.908 23.9999 279.096 24.0001 297.571 56L477.704 368C496.18 400 473.085 440 436.135 440H75.8685C38.918 440 15.8241 400 34.2993 368L214.433 56ZM256.002 144L131.294 360H380.709L256.002 144Z',
],
};

View file

@ -171,7 +171,7 @@ import {
faMinusCircle,
faAdjust,
} from '@fortawesome/free-solid-svg-icons';
import { faVariable, faXmark, faVault, faRefresh } from './custom';
import { faVariable, faXmark, faVault, faRefresh, faTriangle } from './custom';
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
@ -334,6 +334,7 @@ export const FontAwesomePlugin: Plugin = {
addIcon(faToolbox);
addIcon(faTools);
addIcon(faTrash);
addIcon(faTriangle);
addIcon(faUndo);
addIcon(faUnlink);
addIcon(faUser);

View file

@ -101,7 +101,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const isCloudDeployment = computed(() => settings.value.deployment?.type === 'cloud');
const partialExecutionVersion = computed(() => {
const partialExecutionVersion = computed<1 | 2>(() => {
const defaultVersion = settings.value.partialExecution?.version ?? 1;
// -1 means we pick the defaultVersion
// 1 is the old flow
@ -115,7 +115,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
return 1;
}
return version;
return version as 1 | 2;
});
const isAiCreditsEnabled = computed(() => settings.value.aiCredits?.enabled);

View file

@ -277,6 +277,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const getPastChatMessages = computed(() => Array.from(new Set(chatMessages.value)));
const connectionsByDestinationNode = computed(() =>
Workflow.getConnectionsByDestination(workflow.value.connections),
);
function getWorkflowResultDataByNodeName(nodeName: string): ITaskData[] | null {
if (getWorkflowRunData.value === null) {
return null;
@ -295,7 +299,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
}
function incomingConnectionsByNodeName(nodeName: string): INodeConnections {
return getCurrentWorkflow().connectionsByDestinationNode[nodeName] ?? {};
if (connectionsByDestinationNode.value.hasOwnProperty(nodeName)) {
return connectionsByDestinationNode.value[nodeName] as unknown as INodeConnections;
}
return {};
}
function nodeHasOutputConnection(nodeName: string): boolean {
@ -332,6 +339,14 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return nodeMetadata.value[nodeName]?.parametersLastUpdatedAt;
}
function getPinnedDataLastUpdate(nodeName: string): number | undefined {
return nodeMetadata.value[nodeName]?.pinnedDataLastUpdatedAt;
}
function getPinnedDataLastRemovedAt(nodeName: string): number | undefined {
return nodeMetadata.value[nodeName]?.pinnedDataLastRemovedAt;
}
function isNodePristine(nodeName: string): boolean {
return nodeMetadata.value[nodeName] === undefined || nodeMetadata.value[nodeName].pristine;
}
@ -819,6 +834,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
}
function pinData(payload: { node: INodeUi; data: INodeExecutionData[] }): void {
const nodeName = payload.node.name;
if (!workflow.value.pinData) {
workflow.value = { ...workflow.value, pinData: {} };
}
@ -827,6 +844,11 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
payload.data = [payload.data];
}
if ((workflow.value.pinData?.[nodeName] ?? []).length > 0 && nodeMetadata.value[nodeName]) {
// Updating existing pinned data
nodeMetadata.value[nodeName].pinnedDataLastUpdatedAt = Date.now();
}
const storedPinData = payload.data.map((item) =>
isJsonKeyObject(item) ? { json: item.json } : { json: item },
);
@ -835,7 +857,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
...workflow.value,
pinData: {
...workflow.value.pinData,
[payload.node.name]: storedPinData,
[nodeName]: storedPinData,
},
};
@ -846,21 +868,27 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
}
function unpinData(payload: { node: INodeUi }): void {
const nodeName = payload.node.name;
if (!workflow.value.pinData) {
workflow.value = { ...workflow.value, pinData: {} };
}
const { [payload.node.name]: _, ...pinData } = workflow.value.pinData as IPinData;
const { [nodeName]: _, ...pinData } = workflow.value.pinData as IPinData;
workflow.value = {
...workflow.value,
pinData,
};
if (nodeMetadata.value[nodeName]) {
nodeMetadata.value[nodeName].pinnedDataLastRemovedAt = Date.now();
}
uiStore.stateIsDirty = true;
updateCachedWorkflow();
dataPinningEventBus.emit('unpin-data', {
nodeNames: [payload.node.name],
nodeNames: [nodeName],
});
}
@ -1205,9 +1233,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
for (const key of Object.keys(updateInformation.properties)) {
uiStore.stateIsDirty = true;
updateNodeAtIndex(nodeIndex, {
[key]: updateInformation.properties[key],
});
const typedKey = key as keyof INodeUpdatePropertiesInformation['properties'];
const property = updateInformation.properties[typedKey];
updateNodeAtIndex(nodeIndex, { [key]: property });
}
}
}
@ -1230,7 +1259,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
[updateInformation.key]: updateInformation.value,
});
if (updateInformation.key !== 'position') {
const excludeKeys = ['position', 'notes', 'notesInFlow'];
if (!excludeKeys.includes(updateInformation.key)) {
nodeMetadata.value[workflow.value.nodes[nodeIndex].name].parametersLastUpdatedAt = Date.now();
}
}
@ -1693,6 +1724,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
getNodeById,
getNodesByIds,
getParametersLastUpdate,
getPinnedDataLastUpdate,
getPinnedDataLastRemovedAt,
isNodePristine,
isNodeExecuting,
getExecutionDataById,

View file

@ -47,6 +47,16 @@ export const enum CanvasNodeRenderType {
export type CanvasNodeDefaultRenderLabelSize = 'small' | 'medium' | 'large';
export const CanvasNodeDirtiness = {
PARAMETERS_UPDATED: 'parameters-updated',
INCOMING_CONNECTIONS_UPDATED: 'incoming-connections-updated',
PINNED_DATA_UPDATED: 'pinned-data-updated',
UPSTREAM_DIRTY: 'upstream-dirty',
} as const;
export type CanvasNodeDirtinessType =
(typeof CanvasNodeDirtiness)[keyof typeof CanvasNodeDirtiness];
export type CanvasNodeDefaultRender = {
type: CanvasNodeRenderType.Default;
options: Partial<{
@ -60,6 +70,7 @@ export type CanvasNodeDefaultRender = {
labelSize: CanvasNodeDefaultRenderLabelSize;
};
tooltip?: string;
dirtiness?: CanvasNodeDirtinessType;
}>;
};

View file

@ -117,7 +117,9 @@ export class Workflow {
this.connectionsBySourceNode = parameters.connections;
// Save also the connections by the destination nodes
this.connectionsByDestinationNode = this.__getConnectionsByDestination(parameters.connections);
this.connectionsByDestinationNode = Workflow.getConnectionsByDestination(
parameters.connections,
);
this.active = parameters.active || false;
@ -143,7 +145,7 @@ export class Workflow {
* to easily find parent nodes.
*
*/
__getConnectionsByDestination(connections: IConnections): IConnections {
static getConnectionsByDestination(connections: IConnections): IConnections {
const returnConnection: IConnections = {};
let connectionInfo;
@ -446,7 +448,7 @@ export class Workflow {
}
// Use the updated connections to create updated connections by destination nodes
this.connectionsByDestinationNode = this.__getConnectionsByDestination(
this.connectionsByDestinationNode = Workflow.getConnectionsByDestination(
this.connectionsBySourceNode,
);
}

View file

@ -9,7 +9,6 @@ import type {
INode,
INodeExecutionData,
INodeParameters,
INodeTypes,
IRunExecutionData,
NodeParameterValueType,
} from '@/Interfaces';
@ -1871,16 +1870,9 @@ describe('Workflow', () => {
});
});
describe('__getConnectionsByDestination', () => {
describe('getConnectionsByDestination', () => {
it('should return empty object when there are no connections', () => {
const workflow = new Workflow({
nodes: [],
connections: {},
active: false,
nodeTypes: mock<INodeTypes>(),
});
const result = workflow.__getConnectionsByDestination({});
const result = Workflow.getConnectionsByDestination({});
expect(result).toEqual({});
});
@ -1896,13 +1888,7 @@ describe('Workflow', () => {
],
},
};
const workflow = new Workflow({
nodes: [],
connections,
active: false,
nodeTypes: mock<INodeTypes>(),
});
const result = workflow.__getConnectionsByDestination(connections);
const result = Workflow.getConnectionsByDestination(connections);
expect(result).toEqual({
Node2: {
[NodeConnectionType.Main]: [[{ node: 'Node1', type: NodeConnectionType.Main, index: 0 }]],
@ -1926,14 +1912,7 @@ describe('Workflow', () => {
},
};
const workflow = new Workflow({
nodes: [],
connections,
active: false,
nodeTypes: mock<INodeTypes>(),
});
const result = workflow.__getConnectionsByDestination(connections);
const result = Workflow.getConnectionsByDestination(connections);
expect(result).toEqual({
Node2: {
[NodeConnectionType.Main]: [[{ node: 'Node1', type: NodeConnectionType.Main, index: 0 }]],
@ -1953,14 +1932,7 @@ describe('Workflow', () => {
},
};
const workflow = new Workflow({
nodes: [],
connections,
active: false,
nodeTypes: mock<INodeTypes>(),
});
const result = workflow.__getConnectionsByDestination(connections);
const result = Workflow.getConnectionsByDestination(connections);
expect(result).toEqual({});
});
@ -1975,14 +1947,7 @@ describe('Workflow', () => {
},
};
const workflow = new Workflow({
nodes: [],
connections,
active: false,
nodeTypes: mock<INodeTypes>(),
});
const result = workflow.__getConnectionsByDestination(connections);
const result = Workflow.getConnectionsByDestination(connections);
expect(result).toEqual({
Node2: {
[NodeConnectionType.Main]: [[{ node: 'Node1', type: NodeConnectionType.Main, index: 1 }]],
@ -2000,14 +1965,7 @@ describe('Workflow', () => {
},
};
const workflow = new Workflow({
nodes: [],
connections,
active: false,
nodeTypes: mock<INodeTypes>(),
});
const result = workflow.__getConnectionsByDestination(connections);
const result = Workflow.getConnectionsByDestination(connections);
expect(result).toEqual({
Node2: {
[NodeConnectionType.Main]: [