mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
feat(editor): Add node execution status indicator to output panel (#8124)
## Summary Adding node execution status indicator to the output panel ([Figma HiFi](https://www.figma.com/file/iUduV3M4W5wZT7Gw5vgDn1/NDV-output-pane-success-state)). ## Related tickets and issues Fixes ADO-480 ## Review / Merge checklist - [x] PR title and summary are descriptive. **Remember, the title automatically goes into the changelog. Use `(no-changelog)` otherwise.** ([conventions](https://github.com/n8n-io/n8n/blob/master/.github/pull_request_title_conventions.md)) - [ ] [Docs updated](https://github.com/n8n-io/n8n-docs) or follow-up ticket created. - [x] Tests included. > A bug is not considered fixed, unless a test is added to prevent it from happening again. > A feature is not complete without tests.
This commit is contained in:
parent
3a881be6c2
commit
ab74bade05
|
@ -490,6 +490,29 @@ describe('NDV', () => {
|
||||||
ndv.getters.nodeVersion().should('have.text', 'Function node version 1 (Deprecated)');
|
ndv.getters.nodeVersion().should('have.text', 'Function node version 1 (Deprecated)');
|
||||||
ndv.actions.close();
|
ndv.actions.close();
|
||||||
});
|
});
|
||||||
|
it('should properly show node execution indicator', () => {
|
||||||
|
workflowPage.actions.addInitialNodeToCanvas('Code');
|
||||||
|
workflowPage.actions.openNode('Code');
|
||||||
|
// Should not show run info before execution
|
||||||
|
ndv.getters.nodeRunSuccessIndicator().should('not.exist');
|
||||||
|
ndv.getters.nodeRunErrorIndicator().should('not.exist');
|
||||||
|
ndv.getters.nodeExecuteButton().click();
|
||||||
|
ndv.getters.nodeRunSuccessIndicator().should('exist');
|
||||||
|
});
|
||||||
|
it('should properly show node execution indicator for multiple nodes', () => {
|
||||||
|
workflowPage.actions.addInitialNodeToCanvas('Code');
|
||||||
|
workflowPage.actions.openNode('Code');
|
||||||
|
ndv.actions.typeIntoParameterInput('jsCode', 'testets');
|
||||||
|
ndv.getters.backToCanvas().click();
|
||||||
|
workflowPage.actions.executeWorkflow();
|
||||||
|
// Manual tigger node should show success indicator
|
||||||
|
workflowPage.actions.openNode('When clicking "Execute Workflow"');
|
||||||
|
ndv.getters.nodeRunSuccessIndicator().should('exist');
|
||||||
|
// Code node should show error
|
||||||
|
ndv.getters.backToCanvas().click();
|
||||||
|
workflowPage.actions.openNode('Code');
|
||||||
|
ndv.getters.nodeRunErrorIndicator().should('exist');
|
||||||
|
});
|
||||||
it('Should handle mismatched option attributes', () => {
|
it('Should handle mismatched option attributes', () => {
|
||||||
workflowPage.actions.addInitialNodeToCanvas('LDAP', { keepNdvOpen: true, action: 'Create a new entry' });
|
workflowPage.actions.addInitialNodeToCanvas('LDAP', { keepNdvOpen: true, action: 'Create a new entry' });
|
||||||
// Add some attributes in Create operation
|
// Add some attributes in Create operation
|
||||||
|
|
|
@ -98,6 +98,8 @@ export class NDV extends BasePage {
|
||||||
pagination: () => cy.getByTestId('ndv-data-pagination'),
|
pagination: () => cy.getByTestId('ndv-data-pagination'),
|
||||||
nodeVersion: () => cy.getByTestId('node-version'),
|
nodeVersion: () => cy.getByTestId('node-version'),
|
||||||
nodeSettingsTab: () => cy.getByTestId('tab-settings'),
|
nodeSettingsTab: () => cy.getByTestId('tab-settings'),
|
||||||
|
nodeRunSuccessIndicator: () => cy.getByTestId('node-run-info-success'),
|
||||||
|
nodeRunErrorIndicator: () => cy.getByTestId('node-run-info-danger'),
|
||||||
};
|
};
|
||||||
|
|
||||||
actions = {
|
actions = {
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
<div
|
<div
|
||||||
:class="{
|
:class="{
|
||||||
'n8n-info-tip': true,
|
'n8n-info-tip': true,
|
||||||
|
[$style.infoTip]: true,
|
||||||
[$style[theme]]: true,
|
[$style[theme]]: true,
|
||||||
[$style[type]]: true,
|
[$style[type]]: true,
|
||||||
[$style.bold]: bold,
|
[$style.bold]: bold,
|
||||||
|
@ -10,11 +11,11 @@
|
||||||
<n8n-tooltip
|
<n8n-tooltip
|
||||||
v-if="type === 'tooltip'"
|
v-if="type === 'tooltip'"
|
||||||
:placement="tooltipPlacement"
|
:placement="tooltipPlacement"
|
||||||
:popper-class="$style.tooltipPopper"
|
:popperClass="$style.tooltipPopper"
|
||||||
:disabled="type !== 'tooltip'"
|
:disabled="type !== 'tooltip'"
|
||||||
>
|
>
|
||||||
<span :class="$style.iconText">
|
<span :class="$style.iconText" :style="{ color: iconData.color }">
|
||||||
<n8n-icon :icon="theme.startsWith('info') ? 'info-circle' : 'exclamation-triangle'" />
|
<n8n-icon :icon="iconData.icon" />
|
||||||
</span>
|
</span>
|
||||||
<template #content>
|
<template #content>
|
||||||
<span>
|
<span>
|
||||||
|
@ -23,7 +24,7 @@
|
||||||
</template>
|
</template>
|
||||||
</n8n-tooltip>
|
</n8n-tooltip>
|
||||||
<span :class="$style.iconText" v-else>
|
<span :class="$style.iconText" v-else>
|
||||||
<n8n-icon :icon="theme.startsWith('info') ? 'info-circle' : 'exclamation-triangle'" />
|
<n8n-icon :icon="iconData.icon" />
|
||||||
<span>
|
<span>
|
||||||
<slot />
|
<slot />
|
||||||
</span>
|
</span>
|
||||||
|
@ -48,7 +49,7 @@ export default defineComponent({
|
||||||
type: String,
|
type: String,
|
||||||
default: 'info',
|
default: 'info',
|
||||||
validator: (value: string): boolean =>
|
validator: (value: string): boolean =>
|
||||||
['info', 'info-light', 'warning', 'danger'].includes(value),
|
['info', 'info-light', 'warning', 'danger', 'success'].includes(value),
|
||||||
},
|
},
|
||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
|
@ -64,10 +65,50 @@ export default defineComponent({
|
||||||
default: 'top',
|
default: 'top',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
iconData(): { icon: string; color: string } {
|
||||||
|
switch (this.theme) {
|
||||||
|
case 'info':
|
||||||
|
return {
|
||||||
|
icon: 'info-circle',
|
||||||
|
color: '--color-text-light)',
|
||||||
|
};
|
||||||
|
case 'info-light':
|
||||||
|
return {
|
||||||
|
icon: 'info-circle',
|
||||||
|
color: 'var(--color-foreground-dark)',
|
||||||
|
};
|
||||||
|
case 'warning':
|
||||||
|
return {
|
||||||
|
icon: 'exclamation-triangle',
|
||||||
|
color: 'var(--color-warning)',
|
||||||
|
};
|
||||||
|
case 'danger':
|
||||||
|
return {
|
||||||
|
icon: 'exclamation-triangle',
|
||||||
|
color: 'var(--color-danger)',
|
||||||
|
};
|
||||||
|
case 'success':
|
||||||
|
return {
|
||||||
|
icon: 'check-circle',
|
||||||
|
color: 'var(--color-success)',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
icon: 'info-circle',
|
||||||
|
color: '--color-text-light)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
|
.infoTip {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.base {
|
.base {
|
||||||
font-size: var(--font-size-2xs);
|
font-size: var(--font-size-2xs);
|
||||||
line-height: var(--font-size-s);
|
line-height: var(--font-size-s);
|
||||||
|
@ -92,7 +133,7 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip {
|
.tooltipPopper {
|
||||||
composes: base;
|
composes: base;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
@ -101,20 +142,4 @@ export default defineComponent({
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-light {
|
|
||||||
color: var(--color-foreground-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
color: var(--color-text-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
|
||||||
color: var(--color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
exports[`N8nInfoTip > should render correctly as note 1`] = `"<div class="n8n-info-tip info note bold"><span class="iconText"><span class="n8n-text compact size-medium regular n8n-icon n8n-icon"><!----></span><span>Need help doing something?<a href="/docs" target="_blank">Open docs</a></span></span></div>"`;
|
exports[`N8nInfoTip > should render correctly as note 1`] = `"<div class="n8n-info-tip infoTip info note bold"><span class="iconText"><span class="n8n-text compact size-medium regular n8n-icon n8n-icon"><!----></span><span>Need help doing something?<a href="/docs" target="_blank">Open docs</a></span></span></div>"`;
|
||||||
|
|
||||||
exports[`N8nInfoTip > should render correctly as tooltip 1`] = `
|
exports[`N8nInfoTip > should render correctly as tooltip 1`] = `
|
||||||
"<div class="n8n-info-tip info tooltip bold">
|
"<div class="n8n-info-tip infoTip info tooltip bold">
|
||||||
<n8n-tooltip-stub popperclass="tooltipPopper" role="tooltip" showafter="0" hideafter="200" autoclose="0" boundariespadding="0" gpuacceleration="true" offset="12" placement="top" popperoptions="[object Object]" strategy="absolute" effect="dark" enterable="true" pure="false" focusonshow="false" trapping="false" stoppoppermouseevent="true" virtualtriggering="false" content="" rawcontent="false" persistent="false" teleported="true" disabled="false" open="false" trigger="hover" triggerkeys="Enter,Space" arrowoffset="5" showarrow="true" justifybuttons="flex-end" buttons="" class=""></n8n-tooltip-stub>
|
<n8n-tooltip-stub popperclass="tooltipPopper" role="tooltip" showafter="0" hideafter="200" autoclose="0" boundariespadding="0" gpuacceleration="true" offset="12" placement="top" popperoptions="[object Object]" strategy="absolute" effect="dark" enterable="true" pure="false" focusonshow="false" trapping="false" stoppoppermouseevent="true" virtualtriggering="false" content="" rawcontent="false" persistent="false" teleported="true" disabled="false" open="false" trigger="hover" triggerkeys="Enter,Space" arrowoffset="5" showarrow="true" justifybuttons="flex-end" buttons="" class=""></n8n-tooltip-stub>
|
||||||
</div>"
|
</div>"
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<el-tooltip v-bind="{ ...$props, ...$attrs }" popper-class="n8n-tooltip">
|
<el-tooltip v-bind="{ ...$props, ...$attrs }" :popperClass="$props.popperClass ?? 'n8n-tooltip'">
|
||||||
<slot />
|
<slot />
|
||||||
<template #content>
|
<template #content>
|
||||||
<slot name="content">
|
<slot name="content">
|
||||||
|
|
|
@ -36,27 +36,12 @@
|
||||||
{{ $locale.baseText(outputPanelEditMode.enabled ? 'ndv.output.edit' : 'ndv.output') }}
|
{{ $locale.baseText(outputPanelEditMode.enabled ? 'ndv.output.edit' : 'ndv.output') }}
|
||||||
</span>
|
</span>
|
||||||
<RunInfo
|
<RunInfo
|
||||||
v-if="!hasPinData && runsCount === 1"
|
v-if="hasNodeRun && !hasPinData && runsCount === 1"
|
||||||
v-show="!outputPanelEditMode.enabled"
|
v-show="!outputPanelEditMode.enabled"
|
||||||
:taskData="runTaskData"
|
:taskData="runTaskData"
|
||||||
|
:hasStaleData="staleData"
|
||||||
|
:hasPinData="hasPinData"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<n8n-info-tip
|
|
||||||
theme="warning"
|
|
||||||
type="tooltip"
|
|
||||||
tooltipPlacement="right"
|
|
||||||
v-if="hasNodeRun && staleData"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-html="
|
|
||||||
$locale.baseText(
|
|
||||||
hasPinData
|
|
||||||
? 'ndv.output.staleDataWarning.pinData'
|
|
||||||
: 'ndv.output.staleDataWarning.regular',
|
|
||||||
)
|
|
||||||
"
|
|
||||||
></span>
|
|
||||||
</n8n-info-tip>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -352,6 +337,7 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
.titleSection {
|
.titleSection {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
> * {
|
> * {
|
||||||
margin-right: var(--spacing-2xs);
|
margin-right: var(--spacing-2xs);
|
||||||
|
|
|
@ -1,6 +1,36 @@
|
||||||
<template>
|
<template>
|
||||||
<n8n-info-tip type="tooltip" theme="info-light" tooltipPlacement="right" v-if="runMetadata">
|
<n8n-info-tip
|
||||||
|
v-if="hasStaleData"
|
||||||
|
theme="warning"
|
||||||
|
type="tooltip"
|
||||||
|
tooltipPlacement="right"
|
||||||
|
data-test-id="node-run-info-stale"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-html="
|
||||||
|
$locale.baseText(
|
||||||
|
hasPinData
|
||||||
|
? 'ndv.output.staleDataWarning.pinData'
|
||||||
|
: 'ndv.output.staleDataWarning.regular',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
</n8n-info-tip>
|
||||||
|
<n8n-info-tip
|
||||||
|
v-else-if="runMetadata"
|
||||||
|
type="tooltip"
|
||||||
|
:theme="theme"
|
||||||
|
:data-test-id="`node-run-info-${theme}`"
|
||||||
|
tooltipPlacement="right"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
|
<n8n-text :bold="true" size="small"
|
||||||
|
>{{
|
||||||
|
runTaskData.error
|
||||||
|
? $locale.baseText('runData.executionStatus.failed')
|
||||||
|
: $locale.baseText('runData.executionStatus.success')
|
||||||
|
}} </n8n-text
|
||||||
|
><br />
|
||||||
<n8n-text :bold="true" size="small">{{
|
<n8n-text :bold="true" size="small">{{
|
||||||
$locale.baseText('runData.startTime') + ':'
|
$locale.baseText('runData.startTime') + ':'
|
||||||
}}</n8n-text>
|
}}</n8n-text>
|
||||||
|
@ -16,13 +46,19 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import type { ITaskData } from 'n8n-workflow';
|
import type { ITaskData } from 'n8n-workflow';
|
||||||
|
import { convertToDisplayDateComponents } from '@/utils/formatters/dateFormatter';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
taskData: {}, // ITaskData
|
taskData: {}, // ITaskData
|
||||||
|
hasStaleData: Boolean,
|
||||||
|
hasPinData: Boolean,
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
|
theme(): string {
|
||||||
|
return this.runTaskData?.error ? 'danger' : 'success';
|
||||||
|
},
|
||||||
runTaskData(): ITaskData {
|
runTaskData(): ITaskData {
|
||||||
return this.taskData as ITaskData;
|
return this.taskData as ITaskData;
|
||||||
},
|
},
|
||||||
|
@ -30,9 +66,10 @@ export default defineComponent({
|
||||||
if (!this.runTaskData) {
|
if (!this.runTaskData) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const { date, time } = convertToDisplayDateComponents(this.runTaskData.startTime);
|
||||||
return {
|
return {
|
||||||
executionTime: this.runTaskData.executionTime,
|
executionTime: this.runTaskData.executionTime,
|
||||||
startTime: new Date(this.runTaskData.startTime).toLocaleString(),
|
startTime: `${date} at ${time}`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -320,7 +320,7 @@ exports[`RunDataJsonSchema.vue > renders schema for empty data 1`] = `
|
||||||
class="schemaWrapper"
|
class="schemaWrapper"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="n8n-info-tip info note bold"
|
class="n8n-info-tip infoTip info note bold"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="iconText"
|
class="iconText"
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { i18n as locale } from '@/plugins/i18n';
|
import { i18n as locale } from '@/plugins/i18n';
|
||||||
import { genericHelpers } from './genericHelpers';
|
import { genericHelpers } from './genericHelpers';
|
||||||
import type { IExecutionsSummary } from 'n8n-workflow';
|
import type { IExecutionsSummary } from 'n8n-workflow';
|
||||||
|
import { convertToDisplayDateComponents } from '@/utils/formatters/dateFormatter';
|
||||||
|
|
||||||
export interface IExecutionUIData {
|
export interface IExecutionUIData {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -72,7 +73,7 @@ export const executionHelpers = defineComponent({
|
||||||
return status;
|
return status;
|
||||||
},
|
},
|
||||||
formatDate(fullDate: Date | string | number) {
|
formatDate(fullDate: Date | string | number) {
|
||||||
const { date, time } = this.convertToDisplayDate(fullDate);
|
const { date, time } = convertToDisplayDateComponents(fullDate);
|
||||||
return locale.baseText('executionsList.started', { interpolate: { time, date } });
|
return locale.baseText('executionsList.started', { interpolate: { time, date } });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
import dateformat from 'dateformat';
|
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
|
@ -48,14 +47,6 @@ export const genericHelpers = defineComponent({
|
||||||
|
|
||||||
return `${minutesPassed}:${secondsLeft}${this.$locale.baseText('genericHelpers.minShort')}`;
|
return `${minutesPassed}:${secondsLeft}${this.$locale.baseText('genericHelpers.minShort')}`;
|
||||||
},
|
},
|
||||||
convertToDisplayDate(fullDate: Date | string | number): { date: string; time: string } {
|
|
||||||
const mask = `d mmm${
|
|
||||||
new Date(fullDate).getFullYear() === new Date().getFullYear() ? '' : ', yyyy'
|
|
||||||
}#HH:MM:ss`;
|
|
||||||
const formattedDate = dateformat(fullDate, mask);
|
|
||||||
const [date, time] = formattedDate.split('#');
|
|
||||||
return { date, time };
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @note Loading helpers extracted as composable in useLoadingService
|
* @note Loading helpers extracted as composable in useLoadingService
|
||||||
|
|
|
@ -1337,6 +1337,8 @@
|
||||||
"runData.editOutputInvalid.onLine": "On line {line}:",
|
"runData.editOutputInvalid.onLine": "On line {line}:",
|
||||||
"runData.editOutputInvalid.atPosition": "(at position {position})",
|
"runData.editOutputInvalid.atPosition": "(at position {position})",
|
||||||
"runData.editValue": "Edit Value",
|
"runData.editValue": "Edit Value",
|
||||||
|
"runData.executionStatus.success": "Executed successfully",
|
||||||
|
"runData.executionStatus.failed": "Execution failed",
|
||||||
"runData.downloadBinaryData": "Download",
|
"runData.downloadBinaryData": "Download",
|
||||||
"runData.executeNode": "Execute Node",
|
"runData.executeNode": "Execute Node",
|
||||||
"runData.executionTime": "Execution Time",
|
"runData.executionTime": "Execution Time",
|
||||||
|
|
12
packages/editor-ui/src/utils/formatters/dateFormatter.ts
Normal file
12
packages/editor-ui/src/utils/formatters/dateFormatter.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import dateformat from 'dateformat';
|
||||||
|
|
||||||
|
export const convertToDisplayDateComponents = (
|
||||||
|
fullDate: Date | string | number,
|
||||||
|
): { date: string; time: string } => {
|
||||||
|
const mask = `d mmm${
|
||||||
|
new Date(fullDate).getFullYear() === new Date().getFullYear() ? '' : ', yyyy'
|
||||||
|
}#HH:MM:ss`;
|
||||||
|
const formattedDate = dateformat(fullDate, mask);
|
||||||
|
const [date, time] = formattedDate.split('#');
|
||||||
|
return { date, time };
|
||||||
|
};
|
Loading…
Reference in a new issue