Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Draft
wants to merge 17 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions packages/editor-ui/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,7 @@ export interface IUpdateInformation<T extends NodeParameterValueType = NodeParam

export interface INodeUpdatePropertiesInformation {
name: string; // Node-Name
properties: {
position: XYPosition;
[key: string]: IDataObject | XYPosition;
};
properties: Partial<Pick<INodeUi, 'position' | 'credentials' | 'disabled'>>;
}

export type XYPosition = [number, number];
Expand Down Expand Up @@ -849,6 +846,7 @@ export interface ITemplatesNode extends IVersionNode {

export interface INodeMetadata {
parametersLastUpdatedAt?: number;
incomingConnectionsLastUpdatedAt?: number;
pristine: boolean;
}

Expand Down
12 changes: 11 additions & 1 deletion packages/editor-ui/src/components/InputPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -464,7 +465,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>
<FontAwesomeIcon icon="question-circle" />
</N8nTooltip>
</template>
</i18n-t>
</N8nText>
</div>
<div v-else :class="$style.notConnected">
Expand Down
6 changes: 3 additions & 3 deletions packages/editor-ui/src/components/RunData.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1254,12 +1254,12 @@ function onRunIndexChange(run: number) {

function enableNode() {
if (node.value) {
const updateInformation = {
const updateInformation: INodeUpdatePropertiesInformation = {
name: node.value.name,
properties: {
disabled: !node.value.disabled,
} as IDataObject,
} as INodeUpdatePropertiesInformation;
},
};

workflowsStore.updateNodeProperties(updateInformation);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() =>
Expand All @@ -60,6 +66,7 @@ const outputLabelClasses = computed(() => ({
const runDataLabelClasses = computed(() => ({
[$style.label]: true,
[$style.runDataLabel]: true,
[$style.dirty]: renderOptions.value.dirtiness !== undefined,
}));

function onMouseEnter() {
Expand Down Expand Up @@ -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);
}
}
</style>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const props = withDefaults(
handleClasses?: string;
plusSize?: number;
lineSize?: number;
type?: 'success' | 'secondary' | 'default';
type?: 'success' | 'warning' | 'secondary' | 'default';
}>(),
{
position: 'right',
Expand Down Expand Up @@ -163,6 +163,12 @@ function onClick(event: MouseEvent) {
}
}

&.warning {
.line {
stroke: var(--color-warning);
}
}

.plus {
&:hover {
cursor: pointer;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,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,
};
});

Expand Down Expand Up @@ -248,6 +249,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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -51,4 +52,19 @@ 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: 'dirty' } },
},
}),
},
});

expect(getByTestId('canvas-node-status-warning')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -18,9 +20,15 @@ const {
hasRunData,
runDataIterations,
isDisabled,
render,
} = useCanvasNode();

const hideNodeIssues = computed(() => false); // @TODO Implement this
const isStale = computed(
() =>
render.value.type === CanvasNodeRenderType.Default &&
render.value.options.dirtiness === 'dirty',
);
</script>

<template>
Expand Down Expand Up @@ -66,6 +74,18 @@ const hideNodeIssues = computed(() => false); // @TODO Implement this
>
<FontAwesomeIcon icon="sync-alt" spin />
</div>
<div
v-else-if="isStale"
data-test-id="canvas-node-status-warning"
:class="[$style.status, $style.warning]"
>
<N8nTooltip :show-after="500" placement="bottom">
<template #content>
{{ i18n.baseText('node.dirty') }}
</template>
<FontAwesomeIcon icon="exclamation-triangle" />
</N8nTooltip>
</div>
<div
v-else-if="hasRunData"
data-test-id="canvas-node-status-success"
Expand Down Expand Up @@ -126,4 +146,8 @@ const hideNodeIssues = computed(() => false); // @TODO Implement this
.count {
font-size: var(--font-size-s);
}

.warning {
color: var(--color-warning);
}
</style>
41 changes: 29 additions & 12 deletions packages/editor-ui/src/composables/useCanvasMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export function useCanvasMapping({
labelSize: nodeOutputLabelSizeById.value[node.id],
},
tooltip: nodeTooltipById.value[node.id],
dirtiness: workflowsStore.dirtinessByName[node.name],
},
};
}
Expand Down Expand Up @@ -575,20 +576,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 && workflowsStore.dirtinessByName[sourceNodeName] !== undefined) {
return 'warning';
}

if (runDataTotal > 0) {
return 'success';
}

return undefined;
}

const status = getStatus();
const maxConnections = [
...nodeInputsById.value[connection.source],
...nodeInputsById.value[connection.target],
Expand Down
7 changes: 3 additions & 4 deletions packages/editor-ui/src/composables/useNodeHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import type {
ITaskDataConnections,
IRunData,
IBinaryKeyData,
IDataObject,
INode,
INodePropertyOptions,
INodeCredentialsDetails,
Expand Down Expand Up @@ -676,12 +675,12 @@ export function useNodeHelpers() {
}

// Toggle disabled flag
const updateInformation = {
const updateInformation: INodeUpdatePropertiesInformation = {
name: node.name,
properties: {
disabled: newDisabledState,
} as IDataObject,
} as INodeUpdatePropertiesInformation;
},
};

telemetry.track('User set node enabled status', {
node_type: node.type,
Expand Down
1 change: 1 addition & 0 deletions packages/editor-ui/src/composables/useRunWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ describe('useRunWorkflow({ router })', () => {
},
],
};
vi.mocked(workflowsStore).dirtinessByName = { [executeName]: 'dirty' };
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({
name: 'Test Workflow',
getParentNodes: () => [parentName],
Expand Down
27 changes: 5 additions & 22 deletions packages/editor-ui/src/composables/useRunWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,6 @@ import { useExecutionsStore } from '@/stores/executions.store';
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;
};

export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof useRouter> }) {
const nodeHelpers = useNodeHelpers();
const workflowHelpers = useWorkflowHelpers({ router: useRunWorkflowOpts.router });
Expand Down Expand Up @@ -288,10 +269,12 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
}

if (startRunData.runData) {
startRunData.dirtyNodeNames = getDirtyNodeNames(
startRunData.runData,
workflowsStore.getParametersLastUpdate,
const nodeNames = Object.entries(workflowsStore.dirtinessByName).flatMap(
([nodeName, dirtiness]) =>
dirtiness === 'incoming-connections-changed' || dirtiness === 'dirty' ? [nodeName] : [],
);

startRunData.dirtyNodeNames = nodeNames.length > 0 ? nodeNames : undefined;
}

// Init the execution data to represent the start of the execution
Expand Down
4 changes: 3 additions & 1 deletion packages/editor-ui/src/plugins/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -979,7 +979,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...",
Expand Down Expand Up @@ -1060,6 +1061,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)",
Expand Down
Loading
Loading