From 75493ef6ef4ee47d0ccf217cd5c2e58754f60c12 Mon Sep 17 00:00:00 2001 From: autologie Date: Wed, 26 Feb 2025 16:35:17 +0100 Subject: [PATCH] feat(editor): Indicate dirty nodes with yellow borders/connectors on canvas (#13040) --- cypress/composables/ndv.ts | 4 + cypress/e2e/40-manual-partial-execution.cy.ts | 62 +++ .../Test_workflow_partial_execution_v2.json | 74 +++ .../src/components/N8nInfoTip/InfoTip.vue | 18 +- packages/editor-ui/src/Interface.ts | 2 + .../editor-ui/src/components/InputPanel.vue | 13 +- .../editor-ui/src/components/NodeSettings.vue | 2 +- .../editor-ui/src/components/OutputPanel.vue | 16 +- .../ResourceMapper/ResourceMapper.vue | 11 +- packages/editor-ui/src/components/RunInfo.vue | 13 +- .../canvas/elements/edges/CanvasEdge.vue | 2 +- .../render-types/CanvasHandleMainOutput.vue | 2 +- .../nodes/render-types/CanvasNodeDefault.vue | 5 + .../parts/CanvasNodeDisabledStrikeThrough.vue | 10 +- .../parts/CanvasNodeStatusIcons.test.ts | 19 + .../parts/CanvasNodeStatusIcons.vue | 29 +- .../src/composables/useCanvasMapping.ts | 3 + .../composables/useCanvasOperations.test.ts | 4 +- .../src/composables/useCanvasOperations.ts | 40 +- .../src/composables/useHistoryHelper.ts | 14 +- .../src/composables/useNodeDirtiness.test.ts | 464 ++++++++++++++++++ .../src/composables/useNodeDirtiness.ts | 309 ++++++++++++ .../src/composables/useNodeHelpers.ts | 9 +- .../src/composables/useRunWorkflow.test.ts | 22 + .../src/composables/useRunWorkflow.ts | 38 +- packages/editor-ui/src/models/history.ts | 80 +-- .../src/plugins/i18n/locales/en.json | 5 +- .../editor-ui/src/plugins/icons/custom.ts | 12 + packages/editor-ui/src/plugins/icons/index.ts | 3 +- .../editor-ui/src/stores/settings.store.ts | 4 +- .../editor-ui/src/stores/workflows.store.ts | 49 +- packages/editor-ui/src/types/canvas.ts | 11 + packages/workflow/src/Workflow.ts | 8 +- packages/workflow/test/Workflow.test.ts | 56 +-- 34 files changed, 1247 insertions(+), 166 deletions(-) create mode 100644 cypress/fixtures/Test_workflow_partial_execution_v2.json create mode 100644 packages/editor-ui/src/composables/useNodeDirtiness.test.ts create mode 100644 packages/editor-ui/src/composables/useNodeDirtiness.ts diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts index c70e0b20e8..36ae10669b 100644 --- a/cypress/composables/ndv.ts +++ b/cypress/composables/ndv.ts @@ -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'); } diff --git a/cypress/e2e/40-manual-partial-execution.cy.ts b/cypress/e2e/40-manual-partial-execution.cy.ts index 28a1539252..29c8f13e38 100644 --- a/cypress/e2e/40-manual-partial-execution.cy.ts +++ b/cypress/e2e/40-manual-partial-execution.cy.ts @@ -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)), + ); + }); + }); }); diff --git a/cypress/fixtures/Test_workflow_partial_execution_v2.json b/cypress/fixtures/Test_workflow_partial_execution_v2.json new file mode 100644 index 0000000000..c3c8ecc7ae --- /dev/null +++ b/cypress/fixtures/Test_workflow_partial_execution_v2.json @@ -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" + } +} diff --git a/packages/design-system/src/components/N8nInfoTip/InfoTip.vue b/packages/design-system/src/components/N8nInfoTip/InfoTip.vue index 097babef37..8ea89d8f8b 100644 --- a/packages/design-system/src/components/N8nInfoTip/InfoTip.vue +++ b/packages/design-system/src/components/N8nInfoTip/InfoTip.vue @@ -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 = { + 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(), { 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; }); diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 8f238bab7c..8edee2fa37 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -884,6 +884,8 @@ export interface ITemplatesNode extends IVersionNode { export interface INodeMetadata { parametersLastUpdatedAt?: number; + pinnedDataLastUpdatedAt?: number; + pinnedDataLastRemovedAt?: number; pristine: boolean; } diff --git a/packages/editor-ui/src/components/InputPanel.vue b/packages/editor-ui/src/components/InputPanel.vue index 41b85e3389..d1af1ae98d 100644 --- a/packages/editor-ui/src/components/InputPanel.vue +++ b/packages/editor-ui/src/components/InputPanel.vue @@ -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() { /> - {{ i18n.baseText('ndv.input.noOutputData.hint') }} + + +
diff --git a/packages/editor-ui/src/components/NodeSettings.vue b/packages/editor-ui/src/components/NodeSettings.vue index 49cb19ca71..43ee007edb 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/OutputPanel.vue b/packages/editor-ui/src/components/OutputPanel.vue index ac13a67c95..c537f120bc 100644 --- a/packages/editor-ui/src/components/OutputPanel.vue +++ b/packages/editor-ui/src/components/OutputPanel.vue @@ -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') }} { } return field; }); - state.paramValue = { - ...state.paramValue, - schema: newSchema, - }; - emitValueChanged(); + + if (!isEqual(newSchema, state.paramValue.schema)) { + state.paramValue.schema = newSchema; + emitValueChanged(); + } } } diff --git a/packages/editor-ui/src/components/RunInfo.vue b/packages/editor-ui/src/components/RunInfo.vue index f4e68657e5..0f9329be36 100644 --- a/packages/editor-ui/src/components/RunInfo.vue +++ b/packages/editor-ui/src/components/RunInfo.vue @@ -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(() => { 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 385ef7f834..591abf02e0 100644 --- a/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue +++ b/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue @@ -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); 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 89c83b4887..457a869c6e 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 @@ -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); } 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 c30786d28c..e5cd84b3ce 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 @@ -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)); } 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 d292e8f951..89684afd2f 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 55baf76462..7a6aba39f3 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 { 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(); + }); }); 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 aa80598baa..32a23cc42b 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 { 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, +);