mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 21:07:28 -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.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', () => {
|
||||
workflowPage.actions.addInitialNodeToCanvas('LDAP', { keepNdvOpen: true, action: 'Create a new entry' });
|
||||
// Add some attributes in Create operation
|
||||
|
|
|
@ -98,6 +98,8 @@ export class NDV extends BasePage {
|
|||
pagination: () => cy.getByTestId('ndv-data-pagination'),
|
||||
nodeVersion: () => cy.getByTestId('node-version'),
|
||||
nodeSettingsTab: () => cy.getByTestId('tab-settings'),
|
||||
nodeRunSuccessIndicator: () => cy.getByTestId('node-run-info-success'),
|
||||
nodeRunErrorIndicator: () => cy.getByTestId('node-run-info-danger'),
|
||||
};
|
||||
|
||||
actions = {
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
<div
|
||||
:class="{
|
||||
'n8n-info-tip': true,
|
||||
[$style.infoTip]: true,
|
||||
[$style[theme]]: true,
|
||||
[$style[type]]: true,
|
||||
[$style.bold]: bold,
|
||||
|
@ -10,11 +11,11 @@
|
|||
<n8n-tooltip
|
||||
v-if="type === 'tooltip'"
|
||||
:placement="tooltipPlacement"
|
||||
:popper-class="$style.tooltipPopper"
|
||||
:popperClass="$style.tooltipPopper"
|
||||
:disabled="type !== 'tooltip'"
|
||||
>
|
||||
<span :class="$style.iconText">
|
||||
<n8n-icon :icon="theme.startsWith('info') ? 'info-circle' : 'exclamation-triangle'" />
|
||||
<span :class="$style.iconText" :style="{ color: iconData.color }">
|
||||
<n8n-icon :icon="iconData.icon" />
|
||||
</span>
|
||||
<template #content>
|
||||
<span>
|
||||
|
@ -23,7 +24,7 @@
|
|||
</template>
|
||||
</n8n-tooltip>
|
||||
<span :class="$style.iconText" v-else>
|
||||
<n8n-icon :icon="theme.startsWith('info') ? 'info-circle' : 'exclamation-triangle'" />
|
||||
<n8n-icon :icon="iconData.icon" />
|
||||
<span>
|
||||
<slot />
|
||||
</span>
|
||||
|
@ -48,7 +49,7 @@ export default defineComponent({
|
|||
type: String,
|
||||
default: 'info',
|
||||
validator: (value: string): boolean =>
|
||||
['info', 'info-light', 'warning', 'danger'].includes(value),
|
||||
['info', 'info-light', 'warning', 'danger', 'success'].includes(value),
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
|
@ -64,10 +65,50 @@ export default defineComponent({
|
|||
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>
|
||||
|
||||
<style lang="scss" module>
|
||||
.infoTip {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.base {
|
||||
font-size: var(--font-size-2xs);
|
||||
line-height: var(--font-size-s);
|
||||
|
@ -92,7 +133,7 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
.tooltipPopper {
|
||||
composes: base;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
@ -101,20 +142,4 @@ export default defineComponent({
|
|||
display: inline-flex;
|
||||
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>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
// 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`] = `
|
||||
"<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>
|
||||
</div>"
|
||||
`;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<el-tooltip v-bind="{ ...$props, ...$attrs }" popper-class="n8n-tooltip">
|
||||
<el-tooltip v-bind="{ ...$props, ...$attrs }" :popperClass="$props.popperClass ?? 'n8n-tooltip'">
|
||||
<slot />
|
||||
<template #content>
|
||||
<slot name="content">
|
||||
|
|
|
@ -36,27 +36,12 @@
|
|||
{{ $locale.baseText(outputPanelEditMode.enabled ? 'ndv.output.edit' : 'ndv.output') }}
|
||||
</span>
|
||||
<RunInfo
|
||||
v-if="!hasPinData && runsCount === 1"
|
||||
v-if="hasNodeRun && !hasPinData && runsCount === 1"
|
||||
v-show="!outputPanelEditMode.enabled"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
|
@ -352,6 +337,7 @@ export default defineComponent({
|
|||
}
|
||||
.titleSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> * {
|
||||
margin-right: var(--spacing-2xs);
|
||||
|
|
|
@ -1,6 +1,36 @@
|
|||
<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>
|
||||
<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">{{
|
||||
$locale.baseText('runData.startTime') + ':'
|
||||
}}</n8n-text>
|
||||
|
@ -16,13 +46,19 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import type { ITaskData } from 'n8n-workflow';
|
||||
import { convertToDisplayDateComponents } from '@/utils/formatters/dateFormatter';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
taskData: {}, // ITaskData
|
||||
hasStaleData: Boolean,
|
||||
hasPinData: Boolean,
|
||||
},
|
||||
|
||||
computed: {
|
||||
theme(): string {
|
||||
return this.runTaskData?.error ? 'danger' : 'success';
|
||||
},
|
||||
runTaskData(): ITaskData {
|
||||
return this.taskData as ITaskData;
|
||||
},
|
||||
|
@ -30,9 +66,10 @@ export default defineComponent({
|
|||
if (!this.runTaskData) {
|
||||
return null;
|
||||
}
|
||||
const { date, time } = convertToDisplayDateComponents(this.runTaskData.startTime);
|
||||
return {
|
||||
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"
|
||||
>
|
||||
<div
|
||||
class="n8n-info-tip info note bold"
|
||||
class="n8n-info-tip infoTip info note bold"
|
||||
>
|
||||
<span
|
||||
class="iconText"
|
||||
|
|
|
@ -4,6 +4,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
|||
import { i18n as locale } from '@/plugins/i18n';
|
||||
import { genericHelpers } from './genericHelpers';
|
||||
import type { IExecutionsSummary } from 'n8n-workflow';
|
||||
import { convertToDisplayDateComponents } from '@/utils/formatters/dateFormatter';
|
||||
|
||||
export interface IExecutionUIData {
|
||||
name: string;
|
||||
|
@ -72,7 +73,7 @@ export const executionHelpers = defineComponent({
|
|||
return status;
|
||||
},
|
||||
formatDate(fullDate: Date | string | number) {
|
||||
const { date, time } = this.convertToDisplayDate(fullDate);
|
||||
const { date, time } = convertToDisplayDateComponents(fullDate);
|
||||
return locale.baseText('executionsList.started', { interpolate: { time, date } });
|
||||
},
|
||||
},
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { defineComponent } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import dateformat from 'dateformat';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
|
@ -48,14 +47,6 @@ export const genericHelpers = defineComponent({
|
|||
|
||||
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
|
||||
|
|
|
@ -1337,6 +1337,8 @@
|
|||
"runData.editOutputInvalid.onLine": "On line {line}:",
|
||||
"runData.editOutputInvalid.atPosition": "(at position {position})",
|
||||
"runData.editValue": "Edit Value",
|
||||
"runData.executionStatus.success": "Executed successfully",
|
||||
"runData.executionStatus.failed": "Execution failed",
|
||||
"runData.downloadBinaryData": "Download",
|
||||
"runData.executeNode": "Execute Node",
|
||||
"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