diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts
index 7f7e445fde074..e869bb13cd748 100644
--- a/packages/editor-ui/src/Interface.ts
+++ b/packages/editor-ui/src/Interface.ts
@@ -850,6 +850,12 @@ export interface ITemplatesNode extends IVersionNode {
export interface INodeMetadata {
parametersLastUpdatedAt?: number;
+ /**
+ * UNIX timestamp of either when existing pinned data is modified or removed.
+ *
+ * Note that pinning data for the first time isn't supposed to set this field.
+ */
+ pinnedDataUpdatedAt?: number;
pristine: boolean;
}
diff --git a/packages/editor-ui/src/components/InputPanel.vue b/packages/editor-ui/src/components/InputPanel.vue
index 41b85e33890cd..caa605f28f07c 100644
--- a/packages/editor-ui/src/components/InputPanel.vue
+++ b/packages/editor-ui/src/components/InputPanel.vue
@@ -22,6 +22,7 @@ import InputNodeSelect from './InputNodeSelect.vue';
import NodeExecuteButton from './NodeExecuteButton.vue';
import RunData from './RunData.vue';
import WireMeUp from './WireMeUp.vue';
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
type MappingMode = 'debugging' | 'mapping';
@@ -464,7 +465,16 @@ function activatePane() {
/>
- {{ i18n.baseText('ndv.input.noOutputData.hint') }}
+
+
+
+
+ {{ i18n.baseText('ndv.input.noOutputData.hint.tooltip') }}
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/NodeSettings.vue b/packages/editor-ui/src/components/NodeSettings.vue
index 49cb19ca71ae2..43ee007edb37a 100644
--- a/packages/editor-ui/src/components/NodeSettings.vue
+++ b/packages/editor-ui/src/components/NodeSettings.vue
@@ -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,
diff --git a/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue b/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue
index 385ef7f834e1e..7600d0fb2cee9 100644
--- a/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue
+++ b/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue
@@ -67,6 +67,8 @@ const edgeColor = computed(() => {
return 'var(--node-type-supplemental-color)';
} else if (props.selected) {
return 'var(--color-background-dark)';
+ } else if (status.value === 'warning') {
+ return 'var(--color-warning)';
} else {
return 'var(--color-foreground-xdark)';
}
diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue
index 89c83b4887c54..9cc7cebf248c4 100644
--- a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue
+++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue
@@ -41,7 +41,13 @@ const runDataLabel = computed(() =>
const isHandlePlusVisible = computed(() => !isConnecting.value || isHovered.value);
-const plusType = computed(() => (runDataTotal.value > 0 ? 'success' : 'default'));
+const plusType = computed(() =>
+ renderOptions.value.dirtiness !== undefined
+ ? 'warning'
+ : runDataTotal.value > 0
+ ? 'success'
+ : 'default',
+);
const plusLineSize = computed(
() =>
@@ -60,6 +66,7 @@ const outputLabelClasses = computed(() => ({
const runDataLabelClasses = computed(() => ({
[$style.label]: true,
[$style.runDataLabel]: true,
+ [$style.dirty]: renderOptions.value.dirtiness !== undefined,
}));
function onMouseEnter() {
@@ -137,6 +144,10 @@ function onClickAdd() {
transform: translate(-50%, -150%);
font-size: var(--font-size-xs);
color: var(--color-success);
+
+ &.dirty {
+ color: var(--color-warning);
+ }
}
diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue
index 880a9a04017b2..c18f41e3cbe49 100644
--- a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue
+++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue
@@ -7,7 +7,7 @@ const props = withDefaults(
handleClasses?: string;
plusSize?: number;
lineSize?: number;
- type?: 'success' | 'secondary' | 'default';
+ type?: 'success' | 'warning' | 'secondary' | 'default';
}>(),
{
position: 'right',
@@ -163,6 +163,12 @@ function onClick(event: MouseEvent) {
}
}
+ &.warning {
+ .line {
+ stroke: var(--color-warning);
+ }
+ }
+
.plus {
&:hover {
cursor: pointer;
diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue
index c6b8b15d9cd3c..780cc24347a0c 100644
--- a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue
+++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue
@@ -64,6 +64,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,
};
});
@@ -257,6 +258,10 @@ function openContextMenu(event: MouseEvent) {
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));
}
diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeDisabledStrikeThrough.vue b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeDisabledStrikeThrough.vue
index d292e8f951ea3..89684afd2f555 100644
--- a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeDisabledStrikeThrough.vue
+++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeDisabledStrikeThrough.vue
@@ -1,15 +1,19 @@
@@ -31,4 +35,8 @@ const classes = computed(() => {
.success {
border-color: var(--color-success-light);
}
+
+.warning {
+ border-color: var(--color-warning-tint-1);
+}
diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts
index 55baf764620ba..8bdd32386d7d0 100644
--- a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts
+++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts
@@ -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 { 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: 'parameters-updated' },
+ },
+ },
+ }),
+ },
+ });
+
+ expect(getByTestId('canvas-node-status-warning')).toBeInTheDocument();
+ });
});
diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue
index aa80598baa06b..d77c7198e54c3 100644
--- a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue
+++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue
@@ -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 { CanvasNodeRenderType } from '@/types';
+import { N8nTooltip } from 'n8n-design-system';
const nodeHelpers = useNodeHelpers();
const i18n = useI18n();
@@ -18,9 +20,15 @@ const {
hasRunData,
runDataIterations,
isDisabled,
+ render,
} = useCanvasNode();
const hideNodeIssues = computed(() => false); // @TODO Implement this
+const isParameterChanged = computed(
+ () =>
+ render.value.type === CanvasNodeRenderType.Default &&
+ render.value.options.dirtiness === 'parameters-updated',
+);
@@ -66,6 +74,18 @@ const hideNodeIssues = computed(() => false); // @TODO Implement this
>
+
+
+
+ {{ i18n.baseText('node.dirty') }}
+
+
+
+
false); // @TODO Implement this
.count {
font-size: var(--font-size-s);
}
+
+.warning {
+ color: var(--color-warning);
+}
diff --git a/packages/editor-ui/src/composables/useCanvasMapping.ts b/packages/editor-ui/src/composables/useCanvasMapping.ts
index 087a18a662bca..13b7052459b86 100644
--- a/packages/editor-ui/src/composables/useCanvasMapping.ts
+++ b/packages/editor-ui/src/composables/useCanvasMapping.ts
@@ -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],
},
};
}
@@ -580,20 +583,36 @@ export function useCanvasMapping({
const runDataTotal =
nodeExecutionRunDataOutputMapById.value[connection.source]?.[type]?.[index]?.total ?? 0;
- let status: CanvasConnectionData['status'];
- if (nodeExecutionRunningById.value[connection.source]) {
- status = 'running';
- } else if (
- nodePinnedDataById.value[connection.source] &&
- nodeExecutionRunDataById.value[connection.source]
- ) {
- status = 'pinned';
- } else if (nodeHasIssuesById.value[connection.source]) {
- status = 'error';
- } else if (runDataTotal > 0) {
- status = 'success';
+ function getStatus(): CanvasConnectionData['status'] | undefined {
+ if (nodeExecutionRunningById.value[connection.source]) {
+ return 'running';
+ }
+
+ if (
+ nodePinnedDataById.value[connection.source] &&
+ nodeExecutionRunDataById.value[connection.source]
+ ) {
+ return 'pinned';
+ }
+
+ if (nodeHasIssuesById.value[connection.source]) {
+ return 'error';
+ }
+
+ const sourceNodeName = connection.data?.source.node;
+
+ if (sourceNodeName && dirtinessByName.value[sourceNodeName] !== undefined) {
+ return 'warning';
+ }
+
+ if (runDataTotal > 0) {
+ return 'success';
+ }
+
+ return undefined;
}
+ const status = getStatus();
const maxConnections = [
...nodeInputsById.value[connection.source],
...nodeInputsById.value[connection.target],
diff --git a/packages/editor-ui/src/composables/useCanvasOperations.test.ts b/packages/editor-ui/src/composables/useCanvasOperations.test.ts
index bc6f94301b1c9..3f32c107f4f94 100644
--- a/packages/editor-ui/src/composables/useCanvasOperations.test.ts
+++ b/packages/editor-ui/src/composables/useCanvasOperations.test.ts
@@ -706,7 +706,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, Date.now()),
+ );
});
it('should delete node without tracking history', () => {
diff --git a/packages/editor-ui/src/composables/useCanvasOperations.ts b/packages/editor-ui/src/composables/useCanvasOperations.ts
index 5f9d1c86f6e7f..e07d3ac7b639e 100644
--- a/packages/editor-ui/src/composables/useCanvasOperations.ts
+++ b/packages/editor-ui/src/composables/useCanvasOperations.ts
@@ -193,7 +193,9 @@ export function useCanvasOperations({ router }: { router: ReturnType= 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');
diff --git a/packages/editor-ui/src/composables/useNodeDirtiness.test.ts b/packages/editor-ui/src/composables/useNodeDirtiness.test.ts
new file mode 100644
index 0000000000000..099170788f90a
--- /dev/null
+++ b/packages/editor-ui/src/composables/useNodeDirtiness.test.ts
@@ -0,0 +1,190 @@
+/* eslint-disable n8n-local-rules/no-unneeded-backticks */
+import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
+import { useHistoryHelper } from '@/composables/useHistoryHelper';
+import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
+import { useNodeHelpers } from '@/composables/useNodeHelpers';
+import { useSettingsStore } from '@/stores/settings.store';
+import { useWorkflowsStore } from '@/stores/workflows.store';
+import { type FrontendSettings } from '@n8n/api-types';
+import { uniq } from 'lodash-es';
+import { type IConnections, type IRunData, NodeConnectionType } from 'n8n-workflow';
+import { createPinia, setActivePinia } from 'pinia';
+import { type RouteLocationNormalizedLoaded } from 'vue-router';
+
+describe(useNodeDirtiness, () => {
+ let workflowsStore: ReturnType;
+ let settingsStore: ReturnType;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ setActivePinia(createPinia());
+ workflowsStore = useWorkflowsStore();
+ settingsStore = useSettingsStore();
+
+ // Enable new partial execution
+ settingsStore.settings = {
+ partialExecution: { version: 2, enforce: true },
+ } as FrontendSettings;
+ });
+
+ it('should mark nodes with run data older than the last update time as dirty', async () => {
+ expect(
+ await calculateDirtiness({
+ workflow: `
+ a✅
+ b✅
+ c✅
+ `,
+ action: () => workflowsStore.setNodeParameters({ name: 'a', value: 1 }),
+ }),
+ ).toMatchInlineSnapshot(`
+ {
+ "a": "parameters-updated",
+ }
+ `);
+ });
+
+ it('should mark nodes with a dirty node somewhere in its upstream as upstream-dirty', async () => {
+ expect(
+ await calculateDirtiness({
+ workflow: `
+ a✅ -> b✅
+ b -> c✅
+ c -> d
+ `,
+ action: () => workflowsStore.setNodeParameters({ name: 'b', value: 1 }),
+ }),
+ ).toMatchInlineSnapshot(`
+ {
+ "b": "parameters-updated",
+ "c": "upstream-dirty",
+ }
+ `);
+ });
+
+ it('should return even if the connections forms a loop', async () => {
+ expect(
+ await calculateDirtiness({
+ workflow: `
+ a✅ -> b✅
+ b -> c✅
+ c -> d
+ d -> e✅
+ e -> b
+ `,
+ action: () => workflowsStore.setNodeParameters({ name: 'a', value: 1 }),
+ }),
+ ).toMatchInlineSnapshot(`
+ {
+ "a": "parameters-updated",
+ "b": "upstream-dirty",
+ "c": "upstream-dirty",
+ "e": "upstream-dirty",
+ }
+ `);
+ });
+
+ it('should mark downstream nodes of a disabled node dirty', async () => {
+ expect(
+ await calculateDirtiness({
+ workflow: `
+ a✅ -> b✅
+ b -> c✅
+ `,
+ action: () =>
+ useNodeHelpers().disableNodes([workflowsStore.nodesByName.b], { trackHistory: true }),
+ }),
+ ).toMatchInlineSnapshot(`
+ {
+ "c": "incoming-connections-updated",
+ }
+ `);
+ });
+
+ it('should restore original dirtiness after undoing a command', async () => {
+ expect(
+ await calculateDirtiness({
+ workflow: `
+ a✅ -> b✅
+ b -> c✅
+ `,
+ action: async () => {
+ useNodeHelpers().disableNodes([workflowsStore.nodesByName.b], { trackHistory: true });
+ await useHistoryHelper({} as RouteLocationNormalizedLoaded).undo();
+ },
+ }),
+ ).toMatchInlineSnapshot(`{}`);
+ });
+
+ async function calculateDirtiness({
+ workflow,
+ action,
+ }: { workflow: string; action: () => Promise | void }) {
+ const parsedConnections = workflow
+ .split('\n')
+ .filter((line) => line.trim() !== '')
+ .map((line) =>
+ line.split('->').flatMap((node) => {
+ const [name, second] = node.trim().split('✅');
+
+ return name ? [{ name, hasData: second !== undefined }] : [];
+ }),
+ );
+ const nodes = uniq(parsedConnections?.flat()).map(({ name }) => createTestNode({ name }));
+ const connections = parsedConnections?.reduce((conn, [from, to]) => {
+ if (!to) {
+ return conn;
+ }
+
+ const conns = conn[from.name]?.[NodeConnectionType.Main]?.[0] ?? [];
+
+ conn[from.name] = {
+ ...conn[from.name],
+ [NodeConnectionType.Main]: [
+ [...conns, { node: to.name, type: NodeConnectionType.Main, index: conns.length }],
+ ...(conn[from.name]?.Main?.slice(1) ?? []),
+ ],
+ };
+ return conn;
+ }, {});
+ const wf = createTestWorkflow({ nodes, connections });
+
+ workflowsStore.setNodes(wf.nodes);
+ workflowsStore.setConnections(wf.connections);
+
+ workflowsStore.setWorkflowExecutionData({
+ id: wf.id,
+ finished: true,
+ mode: 'manual',
+ status: 'success',
+ workflowData: wf,
+ startedAt: new Date(0),
+ createdAt: new Date(0),
+ data: {
+ resultData: {
+ runData: nodes.reduce((acc, node) => {
+ if (parsedConnections.some((c) => c.some((n) => n.name === node.name && n.hasData))) {
+ acc[node.name] = [
+ {
+ startTime: +new Date('2025-01-01'), // ran before parameter update
+ executionTime: 0,
+ executionStatus: 'success',
+ source: [],
+ },
+ ];
+ }
+
+ return acc;
+ }, {}),
+ },
+ },
+ });
+
+ vi.setSystemTime(new Date('2025-01-02'));
+ await action();
+
+ const { dirtinessByName } = useNodeDirtiness();
+
+ return dirtinessByName.value;
+ }
+});
diff --git a/packages/editor-ui/src/composables/useNodeDirtiness.ts b/packages/editor-ui/src/composables/useNodeDirtiness.ts
new file mode 100644
index 0000000000000..64f040dd50964
--- /dev/null
+++ b/packages/editor-ui/src/composables/useNodeDirtiness.ts
@@ -0,0 +1,174 @@
+import {
+ AddConnectionCommand,
+ AddNodeCommand,
+ BulkCommand,
+ EnableNodeToggleCommand,
+ 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 { type CanvasNodeDirtiness } from '@/types';
+import { type ITaskData, type INodeConnections, NodeConnectionType } from 'n8n-workflow';
+import { computed } from 'vue';
+
+function markDownstreamDirtyRecursively(
+ nodeName: string,
+ dirtiness: Record,
+ visitedNodes: Set,
+ runDataByNode: Record,
+ getOutgoingConnections: (nodeName: string) => INodeConnections,
+): void {
+ if (visitedNodes.has(nodeName)) {
+ return; // prevent infinite recursion
+ }
+
+ visitedNodes.add(nodeName);
+
+ for (const [type, inputConnections] of Object.entries(getOutgoingConnections(nodeName))) {
+ if ((type as NodeConnectionType) !== NodeConnectionType.Main) {
+ continue;
+ }
+
+ for (const connections of inputConnections) {
+ for (const { node } of connections ?? []) {
+ const hasRunData = (runDataByNode[node] ?? []).length > 0;
+
+ if (hasRunData) {
+ dirtiness[node] = dirtiness[node] ?? 'upstream-dirty';
+ }
+
+ markDownstreamDirtyRecursively(
+ node,
+ dirtiness,
+ visitedNodes,
+ runDataByNode,
+ getOutgoingConnections,
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Does the command make the given node dirty?
+ */
+function shouldCommandMarkDirty(
+ command: Undoable,
+ nodeName: string,
+ nodeLastRanAt: number,
+ getIncomingConnections: (nodeName: string) => INodeConnections,
+): boolean {
+ if (nodeLastRanAt > command.getTimestamp()) {
+ return false;
+ }
+
+ if (command instanceof BulkCommand) {
+ return command.commands.some((cmd) =>
+ shouldCommandMarkDirty(cmd, nodeName, nodeLastRanAt, getIncomingConnections),
+ );
+ }
+
+ if (command instanceof AddConnectionCommand) {
+ return command.connectionData[1]?.node === nodeName;
+ }
+
+ if (
+ command instanceof RemoveNodeCommand ||
+ command instanceof AddNodeCommand ||
+ command instanceof EnableNodeToggleCommand
+ ) {
+ const commandTargetNodeName =
+ command instanceof RemoveNodeCommand || command instanceof AddNodeCommand
+ ? command.node.name
+ : command.nodeName;
+
+ return Object.entries(getIncomingConnections(nodeName)).some(([type, nodeInputConnections]) => {
+ switch (type as NodeConnectionType) {
+ case NodeConnectionType.Main:
+ return nodeInputConnections.some((connections) =>
+ connections?.some((connection) => connection.node === commandTargetNodeName),
+ );
+ default:
+ return false;
+ }
+ });
+ }
+
+ return false;
+}
+
+/**
+ * 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();
+
+ const dirtinessByName = computed(() => {
+ // Do not highlight dirtiness if new partial execution is not enabled
+ if (settingsStore.partialExecutionVersion === 1) {
+ return {};
+ }
+
+ const dirtiness: Record = {};
+ const visitedNodes: Set = new Set();
+ const runDataByNode = workflowsStore.getWorkflowRunData ?? {};
+
+ function markDownstreamDirty(nodeName: string) {
+ markDownstreamDirtyRecursively(
+ nodeName,
+ dirtiness,
+ visitedNodes,
+ runDataByNode,
+ workflowsStore.outgoingConnectionsByNodeName,
+ );
+ }
+
+ function shouldMarkDirty(command: Undoable, nodeName: string, nodeLastRanAt: number) {
+ return shouldCommandMarkDirty(
+ command,
+ nodeName,
+ nodeLastRanAt,
+ workflowsStore.incomingConnectionsByNodeName,
+ );
+ }
+
+ for (const node of workflowsStore.allNodes) {
+ const nodeName = node.name;
+ const runAt = runDataByNode[nodeName]?.[0]?.startTime ?? 0;
+
+ if (!runAt) {
+ continue;
+ }
+
+ const parametersLastUpdate = workflowsStore.getParametersLastUpdate(nodeName) ?? 0;
+
+ if (parametersLastUpdate > runAt) {
+ dirtiness[nodeName] = 'parameters-updated';
+ markDownstreamDirty(nodeName);
+ continue;
+ }
+
+ if (historyStore.undoStack.some((command) => shouldMarkDirty(command, nodeName, runAt))) {
+ dirtiness[nodeName] = 'incoming-connections-updated';
+ markDownstreamDirty(nodeName);
+ continue;
+ }
+
+ const pinnedDataUpdatedAt = workflowsStore.getPinnedDataLastUpdate(nodeName) ?? 0;
+
+ if (pinnedDataUpdatedAt > runAt) {
+ dirtiness[nodeName] = 'pinned-data-updated';
+ markDownstreamDirty(nodeName);
+ continue;
+ }
+ }
+
+ return dirtiness;
+ });
+
+ return { dirtinessByName };
+}
diff --git a/packages/editor-ui/src/composables/useNodeHelpers.ts b/packages/editor-ui/src/composables/useNodeHelpers.ts
index 461f550212eba..eee7f040dee02 100644
--- a/packages/editor-ui/src/composables/useNodeHelpers.ts
+++ b/packages/editor-ui/src/composables/useNodeHelpers.ts
@@ -676,7 +676,7 @@ export function useNodeHelpers() {
}
// Toggle disabled flag
- const updateInformation = {
+ const updateInformation: INodeUpdatePropertiesInformation = {
name: node.name,
properties: {
disabled: newDisabledState,
@@ -696,7 +696,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(),
+ ),
);
}
}
@@ -908,7 +913,7 @@ export function useNodeHelpers() {
type: NodeConnectionType.Main,
},
];
- const removeCommand = new RemoveConnectionCommand(connectionData);
+ const removeCommand = new RemoveConnectionCommand(connectionData, Date.now());
historyStore.pushCommandToUndo(removeCommand);
}
}
@@ -1134,7 +1139,7 @@ export function useNodeHelpers() {
if (removeVisualConnection) {
deleteJSPlumbConnection(info.connection, trackHistory);
} else if (trackHistory) {
- historyStore.pushCommandToUndo(new RemoveConnectionCommand(connectionInfo));
+ historyStore.pushCommandToUndo(new RemoveConnectionCommand(connectionInfo, Date.now()));
}
workflowsStore.removeConnection({ connection: connectionInfo });
}
@@ -1235,7 +1240,7 @@ export function useNodeHelpers() {
matchCredentials(newNode);
workflowsStore.addNode(newNode);
if (trackHistory) {
- historyStore.pushCommandToUndo(new AddNodeCommand(newNode));
+ historyStore.pushCommandToUndo(new AddNodeCommand(newNode, Date.now()));
}
});
diff --git a/packages/editor-ui/src/composables/useRunWorkflow.test.ts b/packages/editor-ui/src/composables/useRunWorkflow.test.ts
index 662ea8d44f93d..f02ea12c3cac2 100644
--- a/packages/editor-ui/src/composables/useRunWorkflow.test.ts
+++ b/packages/editor-ui/src/composables/useRunWorkflow.test.ts
@@ -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,8 @@ vi.mock('@/stores/workflows.store', () => ({
nodeIssuesExit: vi.fn(),
checkIfNodeHasChatParent: vi.fn(),
getParametersLastUpdate: vi.fn(),
+ getPinnedDataLastUpdate: vi.fn(),
+ outgoingConnectionsByNodeName: vi.fn(),
}),
}));
@@ -326,6 +332,18 @@ 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) =>
+ ({
+ [parentName]: {
+ main: [[{ node: executeName, type: NodeConnectionType.Main, index: 0 }]],
+ },
+ })[nodeName] ?? ({} as INodeConnections),
+ );
vi.mocked(workflowsStore).getWorkflowRunData = {
[parentName]: [
{
diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts
index 184359cde68c0..67173e316bcdb 100644
--- a/packages/editor-ui/src/composables/useRunWorkflow.ts
+++ b/packages/editor-ui/src/composables/useRunWorkflow.ts
@@ -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((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 }) {
const nodeHelpers = useNodeHelpers();
@@ -73,6 +55,8 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType {
if (!pushConnectionStore.isConnected) {
@@ -229,24 +213,31 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType {
- // Find for each start node the source data
- let sourceData = get(runData, [name, 0, 'source', 0], null);
- if (sourceData === null) {
- const parentNodes = workflow.getParentNodes(name, NodeConnectionType.Main, 1);
- const executeData = workflowHelpers.executeData(
- parentNodes,
- name,
- NodeConnectionType.Main,
- 0,
- );
- sourceData = get(executeData, ['source', NodeConnectionType.Main, 0], null);
- }
- return {
- name,
- sourceData,
- };
- });
+ // partial executions must have a destination node
+ const isPartialExecution = options.destinationNode !== undefined;
+ const version = settingsStore.partialExecutionVersion;
+ debugger;
+ const startNodes: StartNodeData[] =
+ isPartialExecution && version === 2
+ ? []
+ : startNodeNames.map((name) => {
+ // Find for each start node the source data
+ let sourceData = get(runData, [name, 0, 'source', 0], null);
+ if (sourceData === null) {
+ const parentNodes = workflow.getParentNodes(name, NodeConnectionType.Main, 1);
+ const executeData = workflowHelpers.executeData(
+ parentNodes,
+ name,
+ NodeConnectionType.Main,
+ 0,
+ );
+ sourceData = get(executeData, ['source', NodeConnectionType.Main, 0], null);
+ }
+ return {
+ name,
+ sourceData,
+ };
+ });
const singleWebhookTrigger = triggers.find((node) =>
SINGLE_WEBHOOK_TRIGGERS.includes(node.type),
@@ -267,10 +258,6 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType
+ dirtiness ? [nodeName] : [],
);
+
+ startRunData.dirtyNodeNames = nodeNames.length > 0 ? nodeNames : undefined;
}
// Init the execution data to represent the start of the execution
diff --git a/packages/editor-ui/src/models/history.ts b/packages/editor-ui/src/models/history.ts
index f0c90a8856ad5..2a2d8495ba39f 100644
--- a/packages/editor-ui/src/models/history.ts
+++ b/packages/editor-ui/src/models/history.ts
@@ -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;
+
+ 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 {
diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json
index 12301506a37be..202fde80235f1 100644
--- a/packages/editor-ui/src/plugins/i18n/locales/en.json
+++ b/packages/editor-ui/src/plugins/i18n/locales/en.json
@@ -980,7 +980,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...",
@@ -1061,6 +1062,7 @@
"node.delete": "Delete",
"node.add": "Add",
"node.issues": "Issues",
+ "node.dirty": "Output data might be stale, since node parameters have changed",
"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)",
diff --git a/packages/editor-ui/src/stores/canvas.store.ts b/packages/editor-ui/src/stores/canvas.store.ts
index b96699f29704e..7bd9df9311e10 100644
--- a/packages/editor-ui/src/stores/canvas.store.ts
+++ b/packages/editor-ui/src/stores/canvas.store.ts
@@ -294,7 +294,7 @@ export const useCanvasStore = defineStore('canvas', () => {
const oldPosition = node.position;
if (oldPosition[0] !== newNodePosition[0] || oldPosition[1] !== newNodePosition[1]) {
historyStore.pushCommandToUndo(
- new MoveNodeCommand(node.name, oldPosition, newNodePosition),
+ new MoveNodeCommand(node.name, oldPosition, newNodePosition, Date.now()),
);
workflowStore.updateNodeProperties(updateInformation);
}
diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts
index 6e0f2eecd11ae..afbb9a2d327d3 100644
--- a/packages/editor-ui/src/stores/workflows.store.ts
+++ b/packages/editor-ui/src/stores/workflows.store.ts
@@ -328,6 +328,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return nodeMetadata.value[nodeName]?.parametersLastUpdatedAt;
}
+ function getPinnedDataLastUpdate(nodeName: string): number | undefined {
+ return nodeMetadata.value[nodeName]?.pinnedDataUpdatedAt;
+ }
+
function isNodePristine(nodeName: string): boolean {
return nodeMetadata.value[nodeName] === undefined || nodeMetadata.value[nodeName].pristine;
}
@@ -789,6 +793,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: {} };
}
@@ -797,6 +803,11 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
payload.data = [payload.data];
}
+ if ((workflow.value.pinData?.[nodeName] ?? []).length > 0) {
+ // Updating existing pinned data
+ nodeMetadata.value[nodeName].pinnedDataUpdatedAt = Date.now();
+ }
+
const storedPinData = payload.data.map((item) =>
isJsonKeyObject(item) ? { json: item.json } : { json: item },
);
@@ -805,7 +816,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
...workflow.value,
pinData: {
...workflow.value.pinData,
- [payload.node.name]: storedPinData,
+ [nodeName]: storedPinData,
},
};
@@ -816,21 +827,24 @@ 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,
};
+ nodeMetadata.value[nodeName].pinnedDataUpdatedAt = Date.now();
uiStore.stateIsDirty = true;
updateCachedWorkflow();
dataPinningEventBus.emit('unpin-data', {
- nodeNames: [payload.node.name],
+ nodeNames: [nodeName],
});
}
@@ -1175,9 +1189,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 });
}
}
}
@@ -1663,6 +1678,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
getNodeById,
getNodesByIds,
getParametersLastUpdate,
+ getPinnedDataLastUpdate,
isNodePristine,
isNodeExecuting,
getExecutionDataById,
diff --git a/packages/editor-ui/src/types/canvas.ts b/packages/editor-ui/src/types/canvas.ts
index 303423237976d..e1c4ec40d8b43 100644
--- a/packages/editor-ui/src/types/canvas.ts
+++ b/packages/editor-ui/src/types/canvas.ts
@@ -47,6 +47,12 @@ export const enum CanvasNodeRenderType {
export type CanvasNodeDefaultRenderLabelSize = 'small' | 'medium' | 'large';
+export type CanvasNodeDirtiness =
+ | 'parameters-updated'
+ | 'incoming-connections-updated'
+ | 'pinned-data-updated'
+ | 'upstream-dirty';
+
export type CanvasNodeDefaultRender = {
type: CanvasNodeRenderType.Default;
options: Partial<{
@@ -60,6 +66,7 @@ export type CanvasNodeDefaultRender = {
labelSize: CanvasNodeDefaultRenderLabelSize;
};
tooltip?: string;
+ dirtiness?: CanvasNodeDirtiness;
}>;
};
@@ -117,7 +124,7 @@ export type CanvasNode = Node;
export interface CanvasConnectionData {
source: CanvasConnectionPort;
target: CanvasConnectionPort;
- status?: 'success' | 'error' | 'pinned' | 'running';
+ status?: 'success' | 'error' | 'warning' | 'pinned' | 'running';
maxConnections?: number;
}
diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue
index 517eb52239c30..7f8f9c600d7fa 100644
--- a/packages/editor-ui/src/views/NodeView.vue
+++ b/packages/editor-ui/src/views/NodeView.vue
@@ -1700,7 +1700,12 @@ export default defineComponent({
oldPosition[1] !== updateInformation.properties.position[1]
) {
this.historyStore.pushCommandToUndo(
- new MoveNodeCommand(nodeName, oldPosition, updateInformation.properties.position),
+ new MoveNodeCommand(
+ nodeName,
+ oldPosition,
+ updateInformation.properties.position,
+ Date.now(),
+ ),
recordHistory,
);
}
@@ -2914,7 +2919,9 @@ export default defineComponent({
if (!this.isLoading) {
this.uiStore.stateIsDirty = true;
if (!this.suspendRecordingDetachedConnections) {
- this.historyStore.pushCommandToUndo(new AddConnectionCommand(connectionData));
+ this.historyStore.pushCommandToUndo(
+ new AddConnectionCommand(connectionData, Date.now()),
+ );
}
// When we add multiple nodes, this event could be fired hundreds of times for large workflows.
// And because the updateNodesInputIssues() method is quite expensive, we only call it if not in insert mode
@@ -3103,7 +3110,9 @@ export default defineComponent({
.overrideTargetEndpoint as Endpoint | null;
if (connectionInfo) {
- this.historyStore.pushCommandToUndo(new RemoveConnectionCommand(connectionInfo));
+ this.historyStore.pushCommandToUndo(
+ new RemoveConnectionCommand(connectionInfo, Date.now()),
+ );
}
this.connectTwoNodes(
sourceNodeName,
@@ -3122,7 +3131,7 @@ export default defineComponent({
) {
// Ff connection being detached by user, save this in history
// but skip if it's detached as a side effect of bulk undo/redo or node rename process
- const removeCommand = new RemoveConnectionCommand(connectionInfo);
+ const removeCommand = new RemoveConnectionCommand(connectionInfo, Date.now());
this.historyStore.pushCommandToUndo(removeCommand);
}
@@ -3719,7 +3728,7 @@ export default defineComponent({
// Remove node from selected index if found in it
this.uiStore.removeNodeFromSelection(node);
if (trackHistory) {
- this.historyStore.pushCommandToUndo(new RemoveNodeCommand(node));
+ this.historyStore.pushCommandToUndo(new RemoveNodeCommand(node, Date.now()));
}
}); // allow other events to finish like drag stop
@@ -3818,7 +3827,9 @@ export default defineComponent({
workflow.renameNode(currentName, newName);
if (trackHistory) {
- this.historyStore.pushCommandToUndo(new RenameNodeCommand(currentName, newName));
+ this.historyStore.pushCommandToUndo(
+ new RenameNodeCommand(currentName, newName, Date.now()),
+ );
}
// Update also last selected node and execution data