feat(editor): Improve trigger panel (#3509)

* add panel

* add workflow activation hints

* support service trigger nodes

* update polling state

* support more views

* update when trigger panel shows

* update start/error nodes

* add cron/interval info box

* clean up start node

* fix up webhook views

* remove console log

* add listening state

* clean up loading state

* update loading state

* fix up animation

* update views

* add executions hint

* update views

* update accordian styling

* address more issues

* disable execute button if issues

* disable if it has issues

* add stop waiting button

* can activate workflow when dsiabled

* update el

* fix has issues

* add margin bttm

* update views

* close ndv

* add shake

* update copies

* add error when polling node is missing one

* update package lock

* hide switch

* hide binary data that's missing keys

* hide main bar if ndv is open

* remove waiting to execute

* change accordion bg color

* capitalize text

* disable trigger panel in read only views

* remove webhook title

* update webhook desc

* update component

* update webhook executions note

* update header

* update webhook url

* update exec help

* bring back waiting to execute for non triggers

* add transition fade

* set shake

* add helpful tooltip

* add nonactive text

* add inactive text

* hide trigger panel by default

* remove unused import

* update pulse animation

* handle empty values for options

* update text

* add flag for mock manual executions

* add overrides

* Add overrides

* update check

* update package lock; show button for others

* hide more info

* update other core nodes

* update service name

* remove panel from nodes

* update panel

* last tweaks

* add telemetry event

* add telemetry; address issues

* address feedback

* address feedback

* address feedback

* fix previous

* fix previous

* fix bug

* fix bug with webhookbased

* add extra break

* update telemetry

* update telemetry

* add telemetry req

* add info icon story; use icon component

* clean css; en.json

* clean en.json

* rename key

* add key

* sort keys alpha

* handle activation if active + add previous state to telemetry

* stop activation if active

* remove unnessary tracking

* remove unused import

* remove unused

* remove unnessary flag

* rewrite in ts

* move pulse to design system

* clean up

* clean up

* clean up

* disable tslint check

* disable tslint check
This commit is contained in:
Mutasem Aldmour 2022-06-20 21:39:24 +02:00 committed by GitHub
parent 88dea330b9
commit a2f628927d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 44574 additions and 108041 deletions

151188
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
/* tslint:disable:variable-name */
import N8nInfoAccordion from './InfoAccordion.vue';
import { StoryFn } from "@storybook/vue";
export default {
title: 'Atoms/Info Accordion',
component: N8nInfoAccordion,
argTypes: {
},
parameters: {
backgrounds: { default: '--color-background-light' },
},
};
export const Default: StoryFn = (args, {argTypes}) => ({
props: Object.keys(argTypes),
components: {
N8nInfoAccordion,
},
template: '<n8n-info-accordion v-bind="$props" @click="onClick" />',
});
Default.args = {
title: 'my title',
description: 'my description',
};

View file

@ -0,0 +1,87 @@
<template>
<div :class="['accordion', $style.container]" >
<div :class="{[$style.header]: true, [$style.expanded]: expanded}" @click="toggle">
<n8n-text color="text-base" size="small" align="left" bold>{{ title }}</n8n-text>
<n8n-icon
:icon="expanded? 'chevron-up' : 'chevron-down'"
bold
/>
</div>
<div v-if="expanded" :class="{[$style.description]: true, [$style.collapsed]: !expanded}" @click="onClick">
<n8n-text color="text-base" size="small" align="left">
<span v-html="description"></span>
</n8n-text>
</div>
</div>
</template>
<script>
import N8nText from '../N8nText';
import N8nIcon from '../N8nIcon';
export default {
name: 'n8n-info-accordion',
components: {
N8nText,
N8nIcon,
},
props: {
title: {
type: String,
},
description: {
type: String,
},
},
mounted() {
this.$on('expand', () => {
this.expanded = true;
});
},
data() {
return {
expanded: false,
};
},
methods: {
toggle() {
this.expanded = !this.expanded;
},
onClick(e) {
this.$emit('click', e);
},
},
};
</script>
<style lang="scss" module>
.container {
background-color: var(--color-background-base);
}
.header {
cursor: pointer;
display: flex;
padding: var(--spacing-s);
*:first-child {
flex-grow: 1;
}
}
.expanded {
padding: var(--spacing-s) var(--spacing-s) var(--spacing-2xs) var(--spacing-s);
}
.description {
display: flex;
padding: 0 var(--spacing-s) var(--spacing-s) var(--spacing-s);
b {
font-weight: var(--font-weight-bold);
}
}
</style>

View file

@ -0,0 +1,3 @@
import N8nInfoAccordion from './InfoAccordion.vue';
export default N8nInfoAccordion;

View file

@ -74,20 +74,23 @@ export default Vue.extend({
return sanitizeHtml(
text, {
allowedAttributes: { a: ['data-key', 'href', 'target'] },
}
},
);
},
onClick(e) {
if (e.target.localName !== 'a') return;
if (e.target.dataset.key === 'show-less') {
if (e.target.dataset && e.target.dataset.key) {
e.stopPropagation();
e.preventDefault();
this.showFullContent = false;
} else if (this.canTruncate && e.target.dataset.key === 'toggle-expand') {
e.stopPropagation();
e.preventDefault();
this.showFullContent = !this.showFullContent;
if (e.target.dataset.key === 'show-less') {
this.showFullContent = false;
} else if (this.canTruncate && e.target.dataset.key === 'toggle-expand') {
this.showFullContent = !this.showFullContent;
} else {
this.$emit('action', e.target.dataset.key);
}
}
},
},

View file

@ -0,0 +1,21 @@
/* tslint:disable:variable-name */
import N8nPulse from './Pulse.vue';
import { StoryFn } from "@storybook/vue";
export default {
title: 'Atoms/Pulse',
component: N8nPulse,
argTypes: {
},
parameters: {
backgrounds: { default: '--color-background-light' },
},
};
export const Default: StoryFn = (args, {argTypes}) => ({
components: {
N8nPulse,
},
template: '<n8n-pulse> yo </n8n-pulse>',
});

View file

@ -0,0 +1,114 @@
<template>
<div :class="['pulse', $style.pulseContainer]">
<div :class="$style.pulse">
<div :class="$style.pulse2">
<slot></slot>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'n8n-pulse',
};
</script>
<style lang="scss" module>
$--light-pulse-color: hsla(
var(--color-primary-h),
var(--color-primary-s),
var(--color-primary-l),
0.4
);
$--dark-pulse-color: hsla(
var(--color-primary-h),
var(--color-primary-s),
var(--color-primary-l),
0
);
.pulseContainer {
display: flex;
justify-content: center;
align-items: center;
height: 240px;
width: 100%;
}
.pulse {
width: 74px;
height: 74px;
border-radius: 50%;
box-shadow: 0 0 0 $--light-pulse-color;
animation: pulse 6s infinite cubic-bezier(0.33, 1, 0.68, 1);
}
.pulse2 {
display: flex;
justify-content: center;
align-items: center;
width: 74px;
height: 74px;
border-radius: 50%;
box-shadow: 0 0 0 $--light-pulse-color;
animation: pulse2 6s infinite cubic-bezier(0.33, 1, 0.68, 1);
}
@keyframes pulse {
0% {
-moz-box-shadow: 0 0 0 0 $--light-pulse-color;
box-shadow: 0 0 0 0 $--light-pulse-color;
}
58.33% {
// 3s
-moz-box-shadow: 0 0 0 60px $--dark-pulse-color;
box-shadow: 0 0 0 60px $--dark-pulse-color;
}
66.6% {
// 4s
-moz-box-shadow: 0 0 0 66px transparent;
box-shadow: 0 0 0 66px transparent;
}
66.7% {
-moz-box-shadow: 0 0 0 0 transparent;
box-shadow: 0 0 0 0 transparent;
}
}
@keyframes pulse2 {
0% {
-moz-box-shadow: 0 0 0 0 $--light-pulse-color;
box-shadow: 0 0 0 0 $--light-pulse-color;
}
16.66% {
// 1s
-moz-box-shadow: 0 0 0 0 $--light-pulse-color;
box-shadow: 0 0 0 0 $--light-pulse-color;
}
50% {
// 3s
-moz-box-shadow: 0 0 0 60px $--dark-pulse-color;
box-shadow: 0 0 0 60px $--dark-pulse-color;
}
83.3% {
// 5s
-moz-box-shadow: 0 0 0 66px transparent;
box-shadow: 0 0 0 66px transparent;
}
83.4% {
-moz-box-shadow: 0 0 0 0 transparent;
box-shadow: 0 0 0 0 transparent;
}
}
</style>

View file

@ -0,0 +1,3 @@
import N8nPulse from './Pulse.vue';
export default N8nPulse;

View file

@ -1,5 +1,5 @@
<template functional>
<component :is="props.tag" :class="$options.methods.getClasses(props, $style)" :style="$options.methods.getStyles(props)">
<component :is="props.tag" :class="$options.methods.getClasses(props, $style, data)" :style="$options.methods.getStyles(props)" v-on="listeners">
<slot></slot>
</component>
</template>
@ -36,8 +36,13 @@ export default Vue.extend({
},
},
methods: {
getClasses(props: {size: string, bold: boolean}, $style: any) {
return {[$style[`size-${props.size}`]]: true, [$style.bold]: props.bold, [$style.regular]: !props.bold};
getClasses(props: {size: string, bold: boolean}, $style: any, data: any) {
const classes = {[$style[`size-${props.size}`]]: true, [$style.bold]: props.bold, [$style.regular]: !props.bold};
if (data.staticClass) {
classes[data.staticClass] = true;
}
return classes;
},
getStyles(props: {color: string, align: string, compact: false}) {
const styles = {} as any;

View file

@ -33,6 +33,7 @@ import Notification from 'element-ui/lib/notification';
import Popover from 'element-ui/lib/popover';
import CollapseTransition from 'element-ui/lib/transitions/collapse-transition';
import N8nInfoAccordion from './N8nInfoAccordion';
import N8nActionBox from './N8nActionBox';
import N8nActionToggle from './N8nActionToggle';
import N8nAvatar from './N8nAvatar';
@ -56,6 +57,7 @@ import N8nMenuItem from './N8nMenuItem';
import N8nNotice from './N8nNotice';
import N8nLink from './N8nLink';
import N8nOption from './N8nOption';
import N8nPulse from './N8nPulse';
import N8nRadioButtons from './N8nRadioButtons';
import N8nSelect from './N8nSelect';
import N8nSpinner from './N8nSpinner';
@ -72,6 +74,7 @@ import N8nUserSelect from './N8nUserSelect';
import locale from '../locale';
export {
N8nInfoAccordion,
N8nActionBox,
N8nActionToggle,
N8nAvatar,
@ -95,6 +98,7 @@ export {
N8nMenuItem,
N8nNotice,
N8nOption,
N8nPulse,
N8nRadioButtons,
N8nSelect,
N8nSpinner,

View file

@ -39,6 +39,7 @@ import Vue from 'vue';
import Modal from '@/components/Modal.vue';
import { WORKFLOW_ACTIVE_MODAL_KEY, EXECUTIONS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, LOCAL_STORAGE_ACTIVATION_FLAG } from '../constants';
import { getActivatableTriggerNodes, getTriggerNodeServiceName } from './helpers';
import { INodeTypeDescription } from 'n8n-workflow';
export default Vue.extend({
name: 'ActivationModal',
@ -79,12 +80,12 @@ export default Vue.extend({
const trigger = foundTriggers[0];
const triggerNodeType = this.$store.getters.nodeType(trigger.type, trigger.typeVersion);
const triggerNodeType = this.$store.getters.nodeType(trigger.type, trigger.typeVersion) as INodeTypeDescription;
if (triggerNodeType.activationMessage) {
return triggerNodeType.activationMessage;
}
const serviceName = getTriggerNodeServiceName(triggerNodeType.displayName);
const serviceName = getTriggerNodeServiceName(triggerNodeType);
if (trigger.webhookId) {
return this.$locale.baseText('activationModal.yourWorkflowWillNowListenForEvents', {
interpolate: {

View file

@ -1,7 +1,7 @@
<template>
<div>
<n8n-input-label :label="label">
<div :class="$style.copyText" @click="copy">
<div :class="{[$style.copyText]: true, [$style[size]]: true, [$style.collapsed]: collapse}" @click="copy">
<span>{{ value }}</span>
<div :class="$style.copyButton"><span>{{ copyButtonText }}</span></div>
</div>
@ -41,6 +41,14 @@ export default mixins(copyPaste, showMessage).extend({
toastMessage: {
type: String,
},
collapse: {
type: Boolean,
default: false,
},
size: {
type: String,
default: 'large',
},
},
methods: {
copy(): void {
@ -62,8 +70,6 @@ export default mixins(copyPaste, showMessage).extend({
.copyText {
span {
font-family: Monaco, Consolas;
line-height: 1.5;
font-size: var(--font-size-s);
color: var(--color-text-base);
overflow-wrap: break-word;
}
@ -82,6 +88,25 @@ export default mixins(copyPaste, showMessage).extend({
}
}
.large {
span {
font-size: var(--font-size-s);
line-height: 1.5;
}
}
.medium {
span {
font-size: var(--font-size-2xs);
line-height: 1;
}
}
.collapsed {
white-space: nowrap;
overflow: hidden;
}
.copyButton {
display: var(--display-copy-button, none);
position: absolute;

View file

@ -32,7 +32,7 @@
<template v-slot:node-not-run>
<div :class="$style.noOutputData" v-if="parentNodes.length">
<n8n-text tag="div" :bold="true" color="text-dark" size="large">{{ $locale.baseText('ndv.input.noOutputData.title') }}</n8n-text>
<NodeExecuteButton type="outline" :transparent="true" :nodeName="currentNodeName" :label="$locale.baseText('ndv.input.noOutputData.executePrevious')" @execute="onNodeExecute" />
<NodeExecuteButton type="outline" :transparent="true" :nodeName="currentNodeName" :label="$locale.baseText('ndv.input.noOutputData.executePrevious')" @execute="onNodeExecute" telemetrySource="inputs" />
<n8n-text tag="div" size="small">
{{ $locale.baseText('ndv.input.noOutputData.hint') }}
</n8n-text>

View file

@ -1,7 +1,7 @@
<template>
<div>
<div :class="{'main-header': true, expanded: !sidebarMenuCollapsed}">
<div class="top-menu">
<div v-show="!activeNode" class="top-menu">
<ExecutionDetails v-if="isExecutionPage" />
<WorkflowDetails v-else />
</div>
@ -18,6 +18,7 @@ import { pushConnection } from '@/components/mixins/pushConnection';
import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue';
import ExecutionDetails from '@/components/MainHeader/ExecutionDetails/ExecutionDetails.vue';
import { VIEWS } from '@/constants';
import { INodeUi } from '@/Interface';
export default mixins(
pushConnection,
@ -35,6 +36,9 @@ export default mixins(
isExecutionPage (): boolean {
return this.$route.name === VIEWS.EXECUTION;
},
activeNode (): INodeUi | null {
return this.$store.getters.activeNode;
},
},
async mounted() {
// Initialize the push connection

View file

@ -1,6 +1,6 @@
<template>
<div>
<div :class="$style.inputPanel" v-if="!isTriggerNode" :style="inputPanelStyles">
<div :class="$style.inputPanel" :style="inputPanelStyles">
<slot name="input"></slot>
</div>
<div :class="$style.outputPanel" :style="outputPanelStyles">
@ -10,7 +10,7 @@
<div :class="$style.dragButtonContainer" @click="close">
<PanelDragButton
:class="{ [$style.draggable]: true, [$style.visible]: isDragging }"
v-if="!isTriggerNode"
v-if="isDraggable"
:canMoveLeft="canMoveLeft"
:canMoveRight="canMoveRight"
@dragstart="onDragStart"
@ -29,6 +29,9 @@ import PanelDragButton from './PanelDragButton.vue';
const MAIN_PANEL_WIDTH = 360;
const SIDE_MARGIN = 24;
const FIXED_PANEL_WIDTH = 320;
const FIXED_PANEL_WIDTH_LARGE = 420;
const MINIMUM_INPUT_PANEL_WIDTH = 320;
export default Vue.extend({
name: 'NDVDraggablePanels',
@ -36,9 +39,12 @@ export default Vue.extend({
PanelDragButton,
},
props: {
isTriggerNode: {
isDraggable: {
type: Boolean,
},
position: {
type: Number,
},
},
data() {
return {
@ -55,9 +61,20 @@ export default Vue.extend({
window.removeEventListener('resize', this.setTotalWidth);
},
computed: {
fixedPanelWidth() {
if (this.windowWidth > 1700) {
return FIXED_PANEL_WIDTH_LARGE;
}
return FIXED_PANEL_WIDTH;
},
mainPanelPosition(): number {
if (this.isTriggerNode) {
return 0;
if (typeof this.position === 'number') {
return this.position;
}
if (!this.isDraggable) {
return this.fixedPanelWidth + MAIN_PANEL_WIDTH / 2 + SIDE_MARGIN;
}
const relativePosition = this.$store.getters['ui/mainPanelPosition'] as number;
@ -65,7 +82,7 @@ export default Vue.extend({
return relativePosition * this.windowWidth;
},
inputPanelMargin(): number {
return this.isTriggerNode ? 0 : 80;
return !this.isDraggable? 0 : 80;
},
minimumLeftPosition(): number {
return SIDE_MARGIN + this.inputPanelMargin;
@ -93,6 +110,12 @@ export default Vue.extend({
};
},
inputPanelStyles(): { width: string } {
if (!this.isDraggable) {
return {
width: `${this.fixedPanelWidth}px`,
};
}
let width = this.mainPanelPosition - MAIN_PANEL_WIDTH / 2 - SIDE_MARGIN;
width = Math.min(
width,
@ -109,7 +132,7 @@ export default Vue.extend({
width,
this.windowWidth - SIDE_MARGIN * 2 - this.inputPanelMargin - MAIN_PANEL_WIDTH,
);
width = Math.max(320, width);
width = Math.max(MINIMUM_INPUT_PANEL_WIDTH, width);
return {
width: `${width}px`,
};

View file

@ -134,7 +134,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
'node.waitingForYouToCreateAnEventIn',
{
interpolate: {
nodeType: this.nodeType ? getTriggerNodeServiceName(this.nodeType.displayName) : '',
nodeType: this.nodeType ? getTriggerNodeServiceName(this.nodeType) : '',
},
},
);

View file

@ -27,9 +27,24 @@
<div class="data-display" v-if="activeNode">
<div @click="close" :class="$style.modalBackground"></div>
<NDVDraggablePanels :isTriggerNode="isTriggerNode" @close="close" @init="onPanelsInit" @dragstart="onDragStart" @dragend="onDragEnd">
<NDVDraggablePanels
:position="isTriggerNode && !showTriggerPanel ? 0 : undefined"
:isDraggable="!isTriggerNode"
@close="close"
@init="onPanelsInit"
@dragstart="onDragStart"
@dragend="onDragEnd"
>
<template #input>
<TriggerPanel
v-if="showTriggerPanel"
:nodeName="activeNode.name"
:sessionId="sessionId"
@execute="onNodeExecute"
@activate="onWorkflowActivate"
/>
<InputPanel
v-else-if="!isTriggerNode"
:workflow="workflow"
:canLinkRuns="canLinkRuns"
:runIndex="inputRun"
@ -63,6 +78,7 @@
:sessionId="sessionId"
@valueChanged="valueChanged"
@execute="onNodeExecute"
@activate="onWorkflowActivate"
/>
<a
v-if="featureRequestUrl"
@ -100,19 +116,31 @@ import mixins from 'vue-typed-mixins';
import Vue from 'vue';
import OutputPanel from './OutputPanel.vue';
import InputPanel from './InputPanel.vue';
import TriggerPanel from './TriggerPanel.vue';
import { mapGetters } from 'vuex';
import { BASE_NODE_SURVEY_URL, START_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
import {
BASE_NODE_SURVEY_URL,
CRON_NODE_TYPE,
ERROR_TRIGGER_NODE_TYPE,
START_NODE_TYPE,
STICKY_NODE_TYPE,
} from '@/constants';
import { editor } from 'monaco-editor';
import { workflowActivate } from './mixins/workflowActivate';
export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
export default mixins(externalHooks, nodeHelpers, workflowHelpers, workflowActivate).extend({
name: 'NodeDetailsView',
components: {
NodeSettings,
InputPanel,
OutputPanel,
NDVDraggablePanels,
TriggerPanel,
},
props: {
readOnly: {
type: Boolean,
},
renaming: {
type: Boolean,
},
@ -168,6 +196,16 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
}
return null;
},
showTriggerPanel(): boolean {
const isWebhookBasedNode = this.activeNodeType && this.activeNodeType.webhooks && this.activeNodeType.webhooks.length;
const isPollingNode = this.activeNodeType && this.activeNodeType.polling;
const override = this.activeNodeType && this.activeNodeType.triggerPanel;
return Boolean(
!this.readOnly &&
this.isTriggerNode &&
(isWebhookBasedNode || isPollingNode || override),
);
},
workflow(): Workflow {
return this.getWorkflow();
},
@ -325,6 +363,12 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
},
},
methods: {
onWorkflowActivate() {
this.$store.commit('setActiveNode', null);
setTimeout(() => {
this.activateCurrentWorkflow('ndv');
}, 1000);
},
onFeatureRequestClick() {
window.open(this.featureRequestUrl, '_blank');
this.$telemetry.track('User clicked ndv link', {
@ -342,7 +386,7 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
this.isDragging = true;
this.mainPanelPosition = e.position;
},
onDragEnd(e: { windowWidth: number, position: number }) {
onDragEnd(e: { windowWidth: number; position: number }) {
this.isDragging = false;
this.$telemetry.track('User moved parameters pane', {
window_width: e.windowWidth,
@ -473,7 +517,6 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
width: 100%;
display: flex;
}
</style>
<style lang="scss" module>

View file

@ -1,16 +1,22 @@
<template>
<n8n-button
:loading="nodeRunning"
:disabled="workflowRunning && !nodeRunning"
:label="buttonLabel"
:type="type"
:size="size"
:transparentBackground="transparent"
@click="onClick"
/>
<n8n-tooltip placement="bottom" :disabled="!disabledHint">
<div slot="content">{{ disabledHint }}</div>
<div>
<n8n-button
:loading="nodeRunning && !isListeningForEvents"
:disabled="!!disabledHint"
:label="buttonLabel"
:type="type"
:size="size"
:transparentBackground="transparent"
@click="onClick"
/>
</div>
</n8n-tooltip>
</template>
<script lang="ts">
import { WEBHOOK_NODE_TYPE } from '@/constants';
import { INodeUi } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
@ -36,6 +42,9 @@ export default mixins(
type: Boolean,
default: false,
},
telemetrySource: {
type: String,
},
},
computed: {
node (): INodeUi {
@ -64,11 +73,61 @@ export default mixins(
isScheduleTrigger (): boolean {
return !!(this.nodeType && this.nodeType.group.includes('schedule'));
},
isWebhookNode (): boolean {
return Boolean(this.nodeType && this.nodeType.name === WEBHOOK_NODE_TYPE);
},
isListeningForEvents(): boolean {
const waitingOnWebhook = this.$store.getters.executionWaitingForWebhook as boolean;
const executedNode = this.$store.getters.executedNode as string | undefined;
return (
this.node &&
!this.node.disabled &&
this.isTriggerNode &&
waitingOnWebhook &&
(!executedNode || executedNode === this.nodeName)
);
},
hasIssues (): boolean {
return Boolean(this.node && this.node.issues && (this.node.issues.parameters || this.node.issues.credentials));
},
disabledHint(): string {
if (this.isListeningForEvents) {
return '';
}
if (this.isTriggerNode && this.node.disabled) {
return this.$locale.baseText('ndv.execute.nodeIsDisabled');
}
if (this.isTriggerNode && this.hasIssues) {
if (this.$store.getters.activeNode && this.$store.getters.activeNode.name !== this.nodeName) {
return this.$locale.baseText('ndv.execute.fixPrevious');
}
return this.$locale.baseText('ndv.execute.requiredFieldsMissing');
}
if (this.workflowRunning && !this.nodeRunning) {
return this.$locale.baseText('ndv.execute.workflowAlreadyRunning');
}
return '';
},
buttonLabel(): string {
if (this.isListeningForEvents) {
return this.$locale.baseText('ndv.execute.stopListening');
}
if (this.label) {
return this.label;
}
if (this.isPollingTypeNode) {
if (this.isWebhookNode) {
return this.$locale.baseText('ndv.execute.listenForTestEvent');
}
if (this.isPollingTypeNode || (this.nodeType && this.nodeType.mockManualExecution)) {
return this.$locale.baseText('ndv.execute.fetchEvent');
}
@ -80,9 +139,32 @@ export default mixins(
},
},
methods: {
async stopWaitingForWebhook () {
try {
await this.restApi().removeTestWebhook(this.$store.getters.workflowId);
} catch (error) {
this.$showError(
error,
this.$locale.baseText('ndv.execute.stopWaitingForWebhook.error'),
);
return;
}
this.$showMessage({
title: this.$locale.baseText('ndv.execute.stopWaitingForWebhook.success'),
type: 'success',
});
},
onClick() {
this.runWorkflow(this.nodeName, 'RunData.ExecuteNodeButton');
this.$emit('execute');
if (this.isListeningForEvents) {
this.stopWaitingForWebhook();
}
else {
this.$telemetry.track('User clicked execute node button', { node_type: this.nodeName, workflow_id: this.$store.getters.workflowId, source: this.telemetrySource });
this.runWorkflow(this.nodeName, 'RunData.ExecuteNodeButton');
this.$emit('execute');
}
},
},
});

View file

@ -2,11 +2,11 @@
<div :class="{'node-settings': true, 'dragging': dragging}" @keydown.stop>
<div :class="$style.header">
<div class="header-side-menu">
<NodeTitle class="node-name" :value="node.name" :nodeType="nodeType" @input="nameChanged" :readOnly="isReadOnly"></NodeTitle>
<NodeTitle class="node-name" :value="node && node.name" :nodeType="nodeType" @input="nameChanged" :readOnly="isReadOnly"></NodeTitle>
<div
v-if="!isReadOnly"
>
<NodeExecuteButton :nodeName="node.name" @execute="onNodeExecute" size="small" />
<NodeExecuteButton :nodeName="node.name" @execute="onNodeExecute" size="small" telemetrySource="parameters" />
</div>
</div>
<NodeSettingsTabs v-model="openPanel" :nodeType="nodeType" :sessionId="sessionId" />
@ -31,6 +31,7 @@
:parameters="parametersNoneSetting"
:hideDelete="true"
:nodeValues="nodeValues" path="parameters" @valueChanged="valueChanged"
@activate="onWorkflowActivate"
>
<node-credentials
:node="node"
@ -297,6 +298,9 @@ export default mixins(
},
},
methods: {
onWorkflowActivate() {
this.$emit('activate');
},
onNodeExecute () {
this.$emit('execute');
},

View file

@ -9,10 +9,13 @@
<div class="url-selection">
<el-row>
<el-col :span="24">
<el-radio-group v-model="showUrlFor" size="mini">
<el-radio-button label="test">{{ $locale.baseText('nodeWebhooks.testUrl') }}</el-radio-button>
<el-radio-button label="production">{{ $locale.baseText('nodeWebhooks.productionUrl') }}</el-radio-button>
</el-radio-group>
<n8n-radio-buttons
v-model="showUrlFor"
:options="[
{ label: this.$locale.baseText('nodeWebhooks.testUrl'), value: 'test'},
{ label: this.$locale.baseText('nodeWebhooks.productionUrl'), value: 'production'},
]"
/>
</el-col>
</el-row>
</div>
@ -21,7 +24,7 @@
<div class="webhook-wrapper">
<div class="http-field">
<div class="http-method">
{{getValue(webhook, 'httpMethod')}}<br />
{{getWebhookExpressionValue(webhook, 'httpMethod')}}<br />
</div>
</div>
<div class="url-field">
@ -41,7 +44,6 @@
import {
INodeTypeDescription,
IWebhookDescription,
NodeHelpers,
} from 'n8n-workflow';
import { WEBHOOK_NODE_TYPE } from '@/constants';
@ -79,41 +81,23 @@ export default mixins(
},
methods: {
copyWebhookUrl (webhookData: IWebhookDescription): void {
const webhookUrl = this.getWebhookUrl(webhookData);
const webhookUrl = this.getWebhookUrlDisplay(webhookData);
this.copyToClipboard(webhookUrl);
this.$showMessage({
title: this.$locale.baseText('nodeWebhooks.showMessage.title'),
type: 'success',
});
},
getValue (webhookData: IWebhookDescription, key: string): string {
if (webhookData[key] === undefined) {
return 'empty';
}
try {
return this.resolveExpression(webhookData[key] as string) as string;
} catch (e) {
return this.$locale.baseText('nodeWebhooks.invalidExpression');
}
},
getWebhookUrl (webhookData: IWebhookDescription): string {
if (webhookData.restartWebhook === true) {
return '$resumeWebhookUrl';
}
let baseUrl = this.$store.getters.getWebhookUrl;
if (this.showUrlFor === 'test') {
baseUrl = this.$store.getters.getWebhookTestUrl;
}
const workflowId = this.$store.getters.workflowId;
const path = this.getValue(webhookData, 'path');
const isFullPath = this.getValue(webhookData, 'isFullPath') as unknown as boolean || false;
return NodeHelpers.getNodeWebhookUrl(baseUrl, workflowId, this.node, path, isFullPath);
this.$telemetry.track('User copied webhook URL', {
pane: 'parameters',
type: `${this.showUrlFor} url`,
});
},
getWebhookUrlDisplay (webhookData: IWebhookDescription): string {
return this.getWebhookUrl(webhookData);
if (this.node) {
return this.getWebhookUrl(webhookData, this.node, this.showUrlFor);
}
return '';
},
},
watch: {
@ -146,7 +130,7 @@ export default mixins(
}
.http-method {
background-color: green;
background-color: var(--color-foreground-xdark);
width: 40px;
height: 16px;
line-height: 16px;

View file

@ -33,10 +33,8 @@
</template>
<template v-slot:node-not-run>
<n8n-text v-if="workflowRunning">{{ $locale.baseText('ndv.output.waitingToRun') }}</n8n-text>
<n8n-text v-else-if="isPollingTypeNode">{{ $locale.baseText('ndv.output.pollEventNodeHint') }}</n8n-text>
<n8n-text v-else-if="isTriggerNode && !isScheduleTrigger">{{ $locale.baseText('ndv.output.triggerEventNodeHint') }}</n8n-text>
<n8n-text v-else>{{ $locale.baseText('ndv.output.runNodeHint') }}</n8n-text>
<n8n-text v-if="workflowRunning && !isTriggerNode">{{ $locale.baseText('ndv.output.waitingToRun') }}</n8n-text>
<n8n-text v-if="!workflowRunning && (isScheduleTrigger || !isTriggerNode)">{{ $locale.baseText('ndv.output.runNodeHint') }}</n8n-text>
</template>
<template v-slot:no-output-data>
@ -99,7 +97,7 @@ export default Vue.extend({
},
isNodeRunning(): boolean {
const executingNode = this.$store.getters.executingNode;
return executingNode === this.node.name;
return this.node && executingNode === this.node.name;
},
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');

View file

@ -20,6 +20,7 @@
v-else-if="parameter.type === 'notice'"
class="parameter-item"
:content="$locale.nodeText().inputLabelDisplayName(parameter, path)"
@action="onNoticeAction"
/>
<div
@ -274,6 +275,11 @@ export default mixins(
valueChanged (parameterData: IUpdateInformation): void {
this.$emit('valueChanged', parameterData);
},
onNoticeAction(action: string) {
if (action === 'activate') {
this.$emit('activate');
}
},
},
watch: {
filteredParameterNames(newValue, oldValue) {

View file

@ -5,7 +5,7 @@
<div :class="$style.header">
<slot name="header"></slot>
<div v-if="!hasRunError" @click.stop :class="$style.displayModes">
<div v-show="!hasRunError && hasNodeRun && ((jsonData && jsonData.length > 0) || (binaryData && binaryData.length > 0))" @click.stop :class="$style.displayModes">
<n8n-radio-buttons
:value="displayMode"
:options="buttons"
@ -454,7 +454,8 @@ export default mixins(
return [];
}
return this.getBinaryData(this.workflowRunData, this.node.name, this.runIndex, this.currentOutputIndex);
const binaryData = this.getBinaryData(this.workflowRunData, this.node.name, this.runIndex, this.currentOutputIndex);
return binaryData.filter((data) => Boolean(data && Object.keys(data).length));
},
currentOutputIndex(): number {
if (this.overrideOutputs && this.overrideOutputs.length && !this.overrideOutputs.includes(this.outputIndex)) {
@ -883,6 +884,7 @@ export default mixins(
margin-bottom: var(--spacing-s);
padding: var(--spacing-s) var(--spacing-s) 0 var(--spacing-s);
position: relative;
height: 30px;
> *:first-child {
flex-grow: 1;

View file

@ -0,0 +1,463 @@
<template>
<div :class="$style.container">
<transition name="fade" mode="out-in">
<div key="empty" v-if="hasIssues"></div>
<div key="listening" v-else-if="isListeningForEvents">
<n8n-pulse>
<NodeIcon :nodeType="nodeType" :size="40"></NodeIcon>
</n8n-pulse>
<div v-if="isWebhookNode">
<n8n-text tag="div" size="large" color="text-dark" class="mb-2xs" bold>{{
$locale.baseText('ndv.trigger.webhookNode.listening')
}}</n8n-text>
<div :class="$style.shake">
<n8n-text class="mb-xs">
{{
$locale.baseText('ndv.trigger.webhookNode.requestHint', {
interpolate: { type: this.webhookHttpMethod },
})
}}
</n8n-text>
</div>
<CopyInput
:value="webhookTestUrl"
:toastTitle="$locale.baseText('ndv.trigger.copiedTestUrl')"
class="mb-2xl"
size="medium"
:collapse="true"
:copy-button-text="$locale.baseText('generic.clickToCopy')"
@copy="onTestLinkCopied"
></CopyInput>
</div>
<div v-else>
<n8n-text tag="div" size="large" color="text-dark" class="mb-2xs" bold>{{
$locale.baseText('ndv.trigger.webhookBasedNode.listening')
}}</n8n-text>
<div :class="$style.shake">
<n8n-text class="mb-xs" tag="div">
{{
$locale.baseText('ndv.trigger.webhookBasedNode.serviceHint', {
interpolate: { service: serviceName },
})
}}
</n8n-text>
</div>
</div>
</div>
<div key="default" v-else>
<div class="mb-xl" v-if="isActivelyPolling">
<n8n-spinner type="ring" />
</div>
<div :class="$style.action">
<div :class="$style.header">
<n8n-heading v-if="header" tag="h1" bold>
{{ header }}
</n8n-heading>
<n8n-text v-if="subheader">
<span v-html="subheader"></span>
</n8n-text>
</div>
<NodeExecuteButton
v-if="!isActivelyPolling"
:nodeName="nodeName"
@execute="onNodeExecute"
size="medium"
telemetrySource="inputs"
/>
</div>
<n8n-text size="small" @click="onLinkClick" v-if="activationHint">
<span v-html="activationHint"></span>&nbsp;
</n8n-text>
<n8n-link
size="small"
v-if="activationHint && executionsHelp"
@click="expandExecutionHelp"
>{{ $locale.baseText('ndv.trigger.moreInfo') }}</n8n-link
>
<n8n-info-accordion
ref="help"
v-if="executionsHelp"
:class="$style.accordion"
:title="$locale.baseText('ndv.trigger.executionsHint.question')"
:description="executionsHelp"
@click="onLinkClick"
></n8n-info-accordion>
</div>
</transition>
</div>
</template>
<script lang="ts">
import { EXECUTIONS_MODAL_KEY, WEBHOOK_NODE_TYPE, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
import { INodeUi } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow';
import { getTriggerNodeServiceName } from './helpers';
import NodeExecuteButton from './NodeExecuteButton.vue';
import { workflowHelpers } from './mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
import CopyInput from './CopyInput.vue';
import NodeIcon from './NodeIcon.vue';
import { copyPaste } from './mixins/copyPaste';
import { showMessage } from '@/components/mixins/showMessage';
import Vue from 'vue';
export default mixins(workflowHelpers, copyPaste, showMessage).extend({
name: 'TriggerPanel',
components: {
NodeExecuteButton,
CopyInput,
NodeIcon,
},
props: {
nodeName: {
type: String,
},
sessionId: {
type: String,
},
},
computed: {
node(): INodeUi | null {
return this.$store.getters.getNodeByName(this.nodeName);
},
nodeType(): INodeTypeDescription | null {
if (this.node) {
return this.$store.getters.nodeType(this.node.type, this.node.typeVersion);
}
return null;
},
hasIssues(): boolean {
return Boolean(
this.node &&
this.node.issues &&
(this.node.issues.parameters || this.node.issues.credentials),
);
},
serviceName(): string {
if (this.nodeType) {
return getTriggerNodeServiceName(this.nodeType);
}
return '';
},
isWebhookNode(): boolean {
return Boolean(this.node && this.node.type === WEBHOOK_NODE_TYPE);
},
webhookHttpMethod(): string | undefined {
if (
!this.node ||
!this.nodeType ||
!this.nodeType.webhooks ||
!this.nodeType.webhooks.length
) {
return undefined;
}
return this.getWebhookExpressionValue(this.nodeType.webhooks[0], 'httpMethod');
},
webhookTestUrl(): string | undefined {
if (
!this.node ||
!this.nodeType ||
!this.nodeType.webhooks ||
!this.nodeType.webhooks.length
) {
return undefined;
}
return this.getWebhookUrl(this.nodeType.webhooks[0], this.node, 'test');
},
webhookProdUrl(): string | undefined {
if (
!this.node ||
!this.nodeType ||
!this.nodeType.webhooks ||
!this.nodeType.webhooks.length
) {
return undefined;
}
return this.getWebhookUrl(this.nodeType.webhooks[0], this.node, 'prod');
},
isWebhookBasedNode(): boolean {
return Boolean(this.nodeType && this.nodeType.webhooks && this.nodeType.webhooks.length);
},
isPollingNode(): boolean {
return Boolean(this.nodeType && this.nodeType.polling);
},
isListeningForEvents(): boolean {
const waitingOnWebhook = this.$store.getters.executionWaitingForWebhook as boolean;
const executedNode = this.$store.getters.executedNode as string | undefined;
return (
!!this.node &&
!this.node.disabled &&
this.isWebhookBasedNode &&
waitingOnWebhook &&
(!executedNode || executedNode === this.nodeName)
);
},
workflowRunning(): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
isActivelyPolling(): boolean {
const triggeredNode = this.$store.getters.executedNode;
return this.workflowRunning && this.isPollingNode && this.nodeName === triggeredNode;
},
isWorkflowActive(): boolean {
return this.$store.getters.isActive;
},
header(): string {
const serviceName = this.nodeType ? getTriggerNodeServiceName(this.nodeType) : '';
if (this.isActivelyPolling) {
return this.$locale.baseText('ndv.trigger.pollingNode.fetchingEvent');
}
if (
this.nodeType &&
this.nodeType.triggerPanel &&
typeof this.nodeType.triggerPanel.header === 'string'
) {
return this.nodeType.triggerPanel.header;
}
if (this.isWebhookBasedNode) {
return this.$locale.baseText('ndv.trigger.webhookBasedNode.action', {
interpolate: { name: serviceName },
});
}
return '';
},
subheader(): string {
const serviceName = this.nodeType ? getTriggerNodeServiceName(this.nodeType) : '';
if (this.isActivelyPolling) {
return this.$locale.baseText('ndv.trigger.pollingNode.fetchingHint', {
interpolate: { name: serviceName },
});
}
return '';
},
executionsHelp(): string {
if (
this.nodeType &&
this.nodeType.triggerPanel &&
this.nodeType.triggerPanel.executionsHelp !== undefined
) {
if (typeof this.nodeType.triggerPanel.executionsHelp === 'string') {
return this.nodeType.triggerPanel.executionsHelp;
}
if (!this.isWorkflowActive && this.nodeType.triggerPanel.executionsHelp.inactive) {
return this.nodeType.triggerPanel.executionsHelp.inactive;
}
if (this.isWorkflowActive && this.nodeType.triggerPanel.executionsHelp.active) {
return this.nodeType.triggerPanel.executionsHelp.active;
}
}
if (this.isWebhookBasedNode) {
if (this.isWorkflowActive) {
return this.$locale.baseText('ndv.trigger.webhookBasedNode.executionsHelp.active', {
interpolate: { service: this.serviceName },
});
} else {
return this.$locale.baseText('ndv.trigger.webhookBasedNode.executionsHelp.inactive', {
interpolate: { service: this.serviceName },
});
}
}
if (this.isPollingNode) {
if (this.isWorkflowActive) {
return this.$locale.baseText('ndv.trigger.pollingNode.executionsHelp.active', {
interpolate: { service: this.serviceName },
});
} else {
return this.$locale.baseText('ndv.trigger.pollingNode.executionsHelp.inactive', {
interpolate: { service: this.serviceName },
});
}
}
return '';
},
activationHint(): string {
if (this.isActivelyPolling) {
return '';
}
if (
this.nodeType &&
this.nodeType.triggerPanel &&
this.nodeType.triggerPanel.activationHint
) {
if (typeof this.nodeType.triggerPanel.activationHint === 'string') {
return this.nodeType.triggerPanel.activationHint;
}
if (
!this.isWorkflowActive &&
typeof this.nodeType.triggerPanel.activationHint.inactive === 'string'
) {
return this.nodeType.triggerPanel.activationHint.inactive;
}
if (
this.isWorkflowActive &&
typeof this.nodeType.triggerPanel.activationHint.active === 'string'
) {
return this.nodeType.triggerPanel.activationHint.active;
}
}
if (this.isWebhookBasedNode) {
if (this.isWorkflowActive) {
return this.$locale.baseText('ndv.trigger.webhookBasedNode.activationHint.active', {
interpolate: { service: this.serviceName },
});
}
else {
return this.$locale.baseText('ndv.trigger.webhookBasedNode.activationHint.inactive', {
interpolate: { service: this.serviceName },
});
}
}
if (this.isPollingNode) {
if (this.isWorkflowActive) {
return this.$locale.baseText('ndv.trigger.pollingNode.activationHint.active', {
interpolate: { service: this.serviceName },
});
}
else {
return this.$locale.baseText('ndv.trigger.pollingNode.activationHint.inactive', {
interpolate: { service: this.serviceName },
});
}
}
return '';
},
},
methods: {
expandExecutionHelp() {
if (this.$refs.help) {
(this.$refs.help as Vue).$emit('expand');
}
},
onLinkClick(e: MouseEvent) {
if (!e.target) {
return;
}
const target = e.target as HTMLElement;
if (target.localName !== 'a') return;
if (target.dataset && target.dataset.key) {
e.stopPropagation();
e.preventDefault();
if (target.dataset.key === 'activate') {
this.$emit('activate');
} else if (target.dataset.key === 'executions') {
this.$telemetry.track('User clicked ndv link', {
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
pane: 'input',
type: 'open-executions-log',
});
this.$store.commit('setActiveNode', null);
this.$store.dispatch('ui/openModal', EXECUTIONS_MODAL_KEY);
} else if (target.dataset.key === 'settings') {
this.$store.dispatch('ui/openModal', WORKFLOW_SETTINGS_MODAL_KEY);
}
}
},
onTestLinkCopied() {
this.$telemetry.track('User copied webhook URL', {
pane: 'inputs',
type: 'test url',
});
},
onNodeExecute() {
this.$emit('execute');
},
},
});
</script>
<style lang="scss" module>
.container {
position: relative;
width: 100%;
height: 100%;
background-color: var(--color-background-base);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-s) var(--spacing-s) var(--spacing-xl) var(--spacing-s);
text-align: center;
overflow: hidden;
> * {
width: 100%;
}
}
.header {
margin-bottom: var(--spacing-s);
> * {
margin-bottom: var(--spacing-2xs);
}
}
.action {
margin-bottom: var(--spacing-2xl);
}
.shake {
animation: shake 8s infinite;
}
@keyframes shake {
90% {
transform: translateX(0);
}
92.5% {
transform: translateX(6px);
}
95% {
transform: translateX(-6px);
}
97.5% {
transform: translateX(6px);
}
100% {
transform: translateX(0);
}
}
.accordion {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
}
</style>
<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 200ms;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style>

View file

@ -3,11 +3,11 @@
<n8n-tooltip :disabled="!disabled" placement="bottom">
<div slot="content">{{ $locale.baseText('workflowActivator.thisWorkflowHasNoTriggerNodes') }}</div>
<el-switch
v-loading="loading"
v-loading="updatingWorkflowActivation"
:value="workflowActive"
@change="activeChanged"
:title="workflowActive ? $locale.baseText('workflowActivator.deactivateWorkflow') : $locale.baseText('workflowActivator.activateWorkflow')"
:disabled="disabled || loading"
:disabled="disabled || updatingWorkflowActivation"
:active-color="getActiveColor"
inactive-color="#8899AA"
element-loading-spinner="el-icon-loading">
@ -25,28 +25,17 @@
<script lang="ts">
import { externalHooks } from '@/components/mixins/externalHooks';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { workflowActivate } from '@/components/mixins/workflowActivate';
import mixins from 'vue-typed-mixins';
import { mapGetters } from "vuex";
import {
WORKFLOW_ACTIVE_MODAL_KEY,
LOCAL_STORAGE_ACTIVATION_FLAG,
} from '@/constants';
import { getActivatableTriggerNodes } from './helpers';
export default mixins(
externalHooks,
genericHelpers,
restApi,
showMessage,
workflowHelpers,
workflowActivate,
)
.extend(
{
@ -55,11 +44,6 @@ export default mixins(
'workflowActive',
'workflowId',
],
data () {
return {
loading: false,
};
},
computed: {
...mapGetters({
dirtyState: "getStateIsDirty",
@ -98,61 +82,7 @@ export default mixins(
},
methods: {
async activeChanged (newActiveState: boolean) {
this.loading = true;
if (!this.workflowId) {
const saved = await this.saveCurrentWorkflow();
if (!saved) {
this.loading = false;
return;
}
}
try {
if (this.isCurrentWorkflow && this.nodesIssuesExist) {
this.$showMessage({
title: this.$locale.baseText('workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.title'),
message: this.$locale.baseText('workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.message'),
type: 'error',
});
this.loading = false;
return;
}
if (newActiveState) {
this.$telemetry.track('User set workflow active status');
}
await this.updateWorkflow({workflowId: this.workflowId, active: newActiveState});
} catch (error) {
const newStateName = newActiveState === true ? 'activated' : 'deactivated';
this.$showError(
error,
this.$locale.baseText(
'workflowActivator.showError.title',
{ interpolate: { newStateName } },
) + ':',
);
this.loading = false;
return;
}
const activationEventName = this.isCurrentWorkflow ? 'workflow.activeChangeCurrent' : 'workflow.activeChange';
this.$externalHooks().run(activationEventName, { workflowId: this.workflowId, active: newActiveState });
this.$telemetry.track('User set workflow active status', { workflow_id: this.workflowId, is_active: newActiveState });
this.$emit('workflowActiveChanged', { id: this.workflowId, active: newActiveState });
this.loading = false;
if (this.isCurrentWorkflow) {
if (newActiveState && window.localStorage.getItem(LOCAL_STORAGE_ACTIVATION_FLAG) !== 'true') {
this.$store.dispatch('ui/openModal', WORKFLOW_ACTIVE_MODAL_KEY);
}
else {
this.$store.dispatch('settings/fetchPromptsData');
}
}
return this.updateWorkflowActivation(this.workflowId, newActiveState);
},
async displayActivationError () {
let errorMessage: string;

View file

@ -1,6 +1,7 @@
import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, TEMPLATES_NODES_FILTER } from '@/constants';
import { INodeUi, ITemplatesNode } from '@/Interface';
import dateformat from 'dateformat';
import { INodeTypeDescription } from 'n8n-workflow';
const KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
const SI_SYMBOL = ['', 'k', 'M', 'G', 'T', 'P', 'E'];
@ -34,8 +35,8 @@ export function getStyleTokenValue(name: string): string {
return style.getPropertyValue(name);
}
export function getTriggerNodeServiceName(nodeName: string) {
return nodeName.replace(/ trigger/i, '');
export function getTriggerNodeServiceName(nodeType: INodeTypeDescription): string {
return nodeType.displayName.replace(/ trigger/i, '');
}
export function getActivatableTriggerNodes(nodes: INodeUi[]) {

View file

@ -17,6 +17,7 @@ import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
import { getTriggerNodeServiceName } from '../helpers';
export const pushConnection = mixins(
externalHooks,
@ -266,10 +267,29 @@ export const pushConnection = mixins(
const execution = this.$store.getters.getWorkflowExecution;
if (execution && execution.executedNode) {
this.$showMessage({
title: this.$locale.baseText('pushConnection.nodeExecutedSuccessfully'),
type: 'success',
});
const node = this.$store.getters.getNodeByName(execution.executedNode);
const nodeType = node && this.$store.getters.nodeType(node.type, node.typeVersion);
const nodeOutput = execution && execution.executedNode && execution.data && execution.data.resultData && execution.data.resultData.runData && execution.data.resultData.runData[execution.executedNode];
if (node && nodeType && !nodeOutput) {
this.$showMessage({
title: this.$locale.baseText('pushConnection.pollingNode.dataNotFound', {
interpolate: {
service: getTriggerNodeServiceName(nodeType),
},
}),
message: this.$locale.baseText('pushConnection.pollingNode.dataNotFound.message', {
interpolate: {
service: getTriggerNodeServiceName(nodeType),
},
}),
type: 'success',
});
} else {
this.$showMessage({
title: this.$locale.baseText('pushConnection.nodeExecutedSuccessfully'),
type: 'success',
});
}
}
else {
this.$showMessage({

View file

@ -0,0 +1,98 @@
import { externalHooks } from '@/components/mixins/externalHooks';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import mixins from 'vue-typed-mixins';
import { LOCAL_STORAGE_ACTIVATION_FLAG, PLACEHOLDER_EMPTY_WORKFLOW_ID, WORKFLOW_ACTIVE_MODAL_KEY } from '@/constants';
export const workflowActivate = mixins(
externalHooks,
workflowHelpers,
showMessage,
)
.extend({
data() {
return {
updatingWorkflowActivation: false,
};
},
methods: {
async activateCurrentWorkflow(telemetrySource?: string) {
const workflowId = this.$store.getters.workflowId;
return this.updateWorkflowActivation(workflowId, true, telemetrySource);
},
async updateWorkflowActivation(workflowId: string | undefined, newActiveState: boolean, telemetrySource?: string) {
this.updatingWorkflowActivation = true;
const nodesIssuesExist = this.$store.getters.nodesIssuesExist as boolean;
let currWorkflowId: string | undefined = workflowId;
if (!currWorkflowId || currWorkflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
const saved = await this.saveCurrentWorkflow();
if (!saved) {
this.updatingWorkflowActivation = false;
return;
}
currWorkflowId = this.$store.getters.workflowId as string;
}
const isCurrentWorkflow = currWorkflowId === this.$store.getters['workflowId'];
const activeWorkflows = this.$store.getters.getActiveWorkflows;
const isWorkflowActive = activeWorkflows.includes(currWorkflowId);
this.$telemetry.track('User set workflow active status', { workflow_id: currWorkflowId, is_active: newActiveState, previous_status: isWorkflowActive, ndv_input: telemetrySource === 'ndv' });
try {
if (isWorkflowActive && newActiveState) {
this.$showMessage({
title: this.$locale.baseText('workflowActivator.workflowIsActive'),
type: 'success',
});
this.updatingWorkflowActivation = false;
return;
}
if (isCurrentWorkflow && nodesIssuesExist) {
this.$showMessage({
title: this.$locale.baseText('workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.title'),
message: this.$locale.baseText('workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.message'),
type: 'error',
});
this.updatingWorkflowActivation = false;
return;
}
await this.updateWorkflow({workflowId: currWorkflowId, active: newActiveState});
} catch (error) {
const newStateName = newActiveState === true ? 'activated' : 'deactivated';
this.$showError(
error,
this.$locale.baseText(
'workflowActivator.showError.title',
{ interpolate: { newStateName } },
) + ':',
);
this.updatingWorkflowActivation = false;
return;
}
const activationEventName = isCurrentWorkflow ? 'workflow.activeChangeCurrent' : 'workflow.activeChange';
this.$externalHooks().run(activationEventName, { workflowId: currWorkflowId, active: newActiveState });
this.$emit('workflowActiveChanged', { id: currWorkflowId, active: newActiveState });
this.updatingWorkflowActivation = false;
if (isCurrentWorkflow) {
if (newActiveState && window.localStorage.getItem(LOCAL_STORAGE_ACTIVATION_FLAG) !== 'true') {
this.$store.dispatch('ui/openModal', WORKFLOW_ACTIVE_MODAL_KEY);
}
else {
this.$store.dispatch('settings/fetchPromptsData');
}
}
},
},
});

View file

@ -29,6 +29,7 @@ import {
NodeHelpers,
IExecuteData,
INodeConnection,
IWebhookDescription,
} from 'n8n-workflow';
import {
@ -428,6 +429,33 @@ export const workflowHelpers = mixins(
return nodeData;
},
getWebhookExpressionValue (webhookData: IWebhookDescription, key: string): string {
if (webhookData[key] === undefined) {
return 'empty';
}
try {
return this.resolveExpression(webhookData[key] as string) as string;
} catch (e) {
return this.$locale.baseText('nodeWebhooks.invalidExpression');
}
},
getWebhookUrl (webhookData: IWebhookDescription, node: INode, showUrlFor?: string): string {
if (webhookData.restartWebhook === true) {
return '$resumeWebhookUrl';
}
let baseUrl = this.$store.getters.getWebhookUrl;
if (showUrlFor === 'test') {
baseUrl = this.$store.getters.getWebhookTestUrl;
}
const workflowId = this.$store.getters.workflowId;
const path = this.getWebhookExpressionValue(webhookData, 'path');
const isFullPath = this.getWebhookExpressionValue(webhookData, 'isFullPath') as unknown as boolean || false;
return NodeHelpers.getNodeWebhookUrl(baseUrl, workflowId, node, path, isFullPath);
},
resolveParameter(parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[]) {
const itemIndex = 0;

View file

@ -60,12 +60,6 @@ export const workflowRun = mixins(
async runWorkflow (nodeName?: string, source?: string): Promise<IExecutionPushResponse | undefined> {
const workflow = this.getWorkflow();
if(nodeName) {
this.$telemetry.track('User clicked execute node button', { node_type: nodeName, workflow_id: this.$store.getters.workflowId });
} else {
this.$telemetry.track('User clicked execute workflow button', { workflow_id: this.$store.getters.workflowId });
}
if (this.$store.getters.isActionActive('workflowRunning') === true) {
return;
}

View file

@ -44,6 +44,7 @@ import {
Pagination,
Popover,
N8nInfoAccordion,
N8nActionBox,
N8nAvatar,
N8nActionToggle,
@ -70,6 +71,7 @@ import {
N8nTabs,
N8nFormInputs,
N8nFormBox,
N8nPulse,
N8nSquareButton,
N8nTags,
N8nTag,
@ -81,6 +83,7 @@ import { ElMessageBoxOptions } from "element-ui/types/message-box";
Vue.use(Fragment.Plugin);
// n8n design system
Vue.use(N8nInfoAccordion);
Vue.use(N8nActionBox);
Vue.use(N8nActionToggle);
Vue.use(N8nAvatar);
@ -102,6 +105,7 @@ Vue.use(N8nMenu);
Vue.use(N8nMenuItem);
Vue.component('n8n-notice', N8nNotice);
Vue.use(N8nOption);
Vue.use(N8nPulse);
Vue.use(N8nSelect);
Vue.use(N8nSpinner);
Vue.component('n8n-sticky', N8nSticky);

View file

@ -306,8 +306,16 @@
"ndv.backToCanvas.waitingForTriggerWarning": "Waiting for a Trigger node to execute. Close this view to see the Workflow Canvas.",
"ndv.execute.executeNode": "Execute node",
"ndv.execute.executing": "Executing",
"ndv.execute.fetchEvent": "Fetch Event",
"ndv.execute.fetchEvent": "Fetch Test Event",
"ndv.execute.fixPrevious": "Fix previous node first",
"ndv.execute.listenForEvent": "Listen For Event",
"ndv.execute.listenForTestEvent": "Listen For Test Event",
"ndv.execute.stopListening": "Stop Listening",
"ndv.execute.nodeIsDisabled": "Enable node to execute",
"ndv.execute.requiredFieldsMissing": "Complete required fields first",
"ndv.execute.stopWaitingForWebhook.error": "Problem deleting test webhook",
"ndv.execute.stopWaitingForWebhook.success": "Deleted test webhook",
"ndv.execute.workflowAlreadyRunning": "Workflow is already running",
"ndv.featureRequest": "I wish this node would...",
"ndv.input": "Input",
"ndv.input.nodeDistance": "({count} node back) | ({count} nodes back)",
@ -336,14 +344,12 @@
"ndv.output.noOutputDataInBranch": "No output data in this branch",
"ndv.output.of": " of ",
"ndv.output.pageSize": "Page Size",
"ndv.output.pollEventNodeHint": "Fetch an event to output data",
"ndv.output.run": "Run",
"ndv.output.runNodeHint": "Execute this node to output data",
"ndv.output.staleDataWarning": "Node parameters have changed. <br /> Execute node again to refresh output.",
"ndv.output.tooMuchData.message": "The node contains {size} MB of data. Displaying it may cause problems. <br /> If you do decide to display it, avoid the JSON view.",
"ndv.output.tooMuchData.showDataAnyway": "Show data anyway",
"ndv.output.tooMuchData.title": "Output data is huge!",
"ndv.output.triggerEventNodeHint": "Listen for an event to output data",
"ndv.output.waitingToRun": "Waiting to execute...",
"ndv.title.cancel": "Cancel",
"ndv.title.rename": "Rename",
@ -614,6 +620,8 @@
"pushConnection.workflowExecutedSuccessfully": "Workflow executed successfully",
"pushConnectionTracker.cannotConnectToServer": "You have a connection issue or the server is down. <br />n8n should reconnect automatically once the issue is resolved.",
"pushConnectionTracker.connectionLost": "Connection lost",
"pushConnection.pollingNode.dataNotFound": "No {service} data found",
"pushConnection.pollingNode.dataNotFound.message": "We didnt find any data in {service} to simulate an event. Please create one in {service} and try again.",
"runData.emptyItemHint": "This is an item, but it's empty.",
"runData.switchToBinary.info": "This item only has",
"runData.switchToBinary.binary": "binary data",
@ -795,6 +803,24 @@
"timeAgo.rightNow": "Right now",
"timeAgo.weeksAgo": "%s weeks ago",
"timeAgo.yearsAgo": "%s years ago",
"ndv.trigger.moreInfo": "More info",
"ndv.trigger.copiedTestUrl": "Test URL copied to clipboard",
"ndv.trigger.webhookBasedNode.executionsHelp.inactive": "<b>While building your workflow</b>, click the 'listen' button, then go to {service} and make an event happen. This will trigger an execution, which will show up in this editor.<br /> <br /> <b>Once you're happy with your workflow</b>, <a data-key=\"activate\">activate</a> it. Then every time there's a matching event in {service}, the workflow will execute. These executions will show up in the <a data-key=\"executions\">executions list</a>, but not in the editor.",
"ndv.trigger.webhookBasedNode.executionsHelp.active": "<b>While building your workflow</b>, click the 'listen' button, then go to {service} and make an event happen. This will trigger an execution, which will show up in this editor.<br /> <br /> <b>Your workflow will also execute automatically</b>, since it's activated. Every time theres a matching event in {service}, this node will trigger an execution. These executions will show up in the <a data-key=\"executions\">executions list</a>, but not in the editor. ",
"ndv.trigger.webhookNode.listening": "Listening for test event",
"ndv.trigger.webhookBasedNode.listening": "Listening for your trigger event",
"ndv.trigger.webhookNode.requestHint": "Make a {type} request to:",
"ndv.trigger.webhookBasedNode.serviceHint": "Go to {service} and create an event",
"ndv.trigger.webhookBasedNode.activationHint.inactive": "Once youve finished building your workflow, <a data-key=\"activate\">activate it</a> to have it also listen continuously (you just wont see those executions here).",
"ndv.trigger.webhookBasedNode.activationHint.active": "This node will also trigger automatically on new {service} events (but those executions wont show up here).",
"ndv.trigger.pollingNode.activationHint.inactive": "Once youve finished building your workflow, <a data-key=\"activate\">activate it</a> to have it also check for events regularly (you just wont see those executions here).",
"ndv.trigger.pollingNode.activationHint.active": "This node will also trigger automatically on new {service} events (but those executions wont show up here).",
"ndv.trigger.executionsHint.question": "When will this node trigger my flow?",
"ndv.trigger.pollingNode.fetchingEvent": "Fetching event",
"ndv.trigger.pollingNode.fetchingHint": "This node is looking for an event in {name} that is similar to the one you defined",
"ndv.trigger.pollingNode.executionsHelp.inactive": "<b>While building your workflow</b>, click the 'fetch' button to fetch a single mock event. It will show up in this editor.<br /><br /><b>Once you're happy with your workflow</b>, <a data-key=\"activate\">activate</a> it. Then n8n will regularly check {service} for new events, and execute this workflow if it finds any. These executions will show up in the <a data-key=\"executions\">executions list</a>, but not in the editor.",
"ndv.trigger.pollingNode.executionsHelp.active": "<b>While building your workflow</b>, click the 'fetch' button to fetch a single mock event. It will show up in this editor.<br /><br /><b>Your workflow will also execute automatically</b>, since it's activated. n8n will regularly check {app_name} for new events, and execute this workflow if it finds any. These executions will show up in the <a data-key=\"executions\">executions list</a>, but not in the editor.",
"ndv.trigger.webhookBasedNode.action": "Pull in events from {name}",
"updatesPanel.andIs": "and is",
"updatesPanel.behindTheLatest": "behind the latest and greatest n8n",
"updatesPanel.howToUpdateYourN8nVersion": "How to update your n8n version",
@ -815,6 +841,7 @@
"versionCard.thisVersionHasASecurityIssue": "This version has a security issue.<br />It is listed here for completeness.",
"versionCard.unknown": "unknown",
"versionCard.version": "Version",
"workflowActivator.workflowIsActive": "Workflow is already active",
"workflowActivator.activateWorkflow": "Activate workflow",
"workflowActivator.deactivateWorkflow": "Deactivate workflow",
"workflowActivator.showError.title": "Workflow could not be {newStateName}",

View file

@ -176,7 +176,7 @@ export const store = new Vuex.Store({
setWorkflowInactive (state, workflowId: string) {
const index = state.activeWorkflows.indexOf(workflowId);
if (index !== -1) {
state.selectedNodes.splice(index, 1);
state.activeWorkflows.splice(index, 1);
}
},
// Set state condition dirty or not

View file

@ -29,7 +29,7 @@
@deselectNode="nodeDeselectedByName"
@nodeSelected="nodeSelectedByName"
@removeNode="removeNode"
@runWorkflow="runWorkflow"
@runWorkflow="onRunNode"
@moved="onNodeMoved"
@run="onNodeRun"
:id="'node-' + getNodeIndex(nodeData.name)"
@ -58,7 +58,7 @@
</div>
</div>
</div>
<NodeDetailsView :renaming="renamingActive" @valueChanged="valueChanged"/>
<NodeDetailsView :readOnly="isReadOnly" :renaming="renamingActive" @valueChanged="valueChanged"/>
<div
:class="['node-buttons-wrapper', showStickyButton ? 'no-events' : '']"
v-if="!createNodeActive && !isReadOnly"
@ -104,7 +104,7 @@
</div>
<div class="workflow-execute-wrapper" v-if="!isReadOnly">
<n8n-button
@click.stop="runWorkflow()"
@click.stop="onRunWorkflow"
:loading="workflowRunning"
:label="runButtonText"
size="large"
@ -404,6 +404,14 @@ export default mixins(
document.removeEventListener('keyup', this.keyUp);
},
methods: {
onRunNode(nodeName: string, source: string) {
this.$telemetry.track('User clicked execute node button', { node_type: nodeName, workflow_id: this.$store.getters.workflowId, source: 'canvas' });
this.runWorkflow(nodeName, source);
},
onRunWorkflow() {
this.$telemetry.track('User clicked execute workflow button', { workflow_id: this.$store.getters.workflowId });
this.runWorkflow();
},
onCreateMenuHoverIn(mouseinEvent: MouseEvent) {
const buttonsWrapper = mouseinEvent.target as Element;

View file

@ -35,6 +35,12 @@ export class Cron implements INodeType {
inputs: [],
outputs: ['main'],
properties: [
{
displayName: 'This workflow will run on the schedule you define here once you <a data-key="activate">activate</a> it.<br><br>For testing, you can also trigger it manually: by going back to the canvas and clicking execute workflow',
name: 'notice',
type: 'notice',
default: '',
},
{
displayName: 'Trigger Times',
name: 'triggerTimes',

View file

@ -15,6 +15,7 @@ export class ErrorTrigger implements INodeType {
version: 1,
description: 'Triggers the workflow when another workflow has an error',
eventTriggerDescription: '',
mockManualExecution: true,
maxNodes: 1,
defaults: {
name: 'Error Trigger',
@ -22,7 +23,14 @@ export class ErrorTrigger implements INodeType {
},
inputs: [],
outputs: ['main'],
properties: [],
properties: [
{
displayName: 'This node will trigger when there is an error in another workflow, as long as that workflow is set up to do so. <a href="https://docs.n8n.io/integrations/core-nodes/n8n-nodes-base.errortrigger" target="_blank">More info<a>',
name: 'notice',
type: 'notice',
default: '',
},
],
};

View file

@ -25,6 +25,12 @@ export class Interval implements INodeType {
inputs: [],
outputs: ['main'],
properties: [
{
displayName: 'This workflow will run on the schedule you define here once you <a data-key="activate">activate</a> it.<br><br>For testing, you can also trigger it manually: by going back to the canvas and clicking execute workflow',
name: 'notice',
type: 'notice',
default: '',
},
{
displayName: 'Interval',
name: 'interval',

View file

@ -16,6 +16,7 @@ export class N8nTrigger implements INodeType {
version: 1,
description: 'Handle events from your n8n instance',
eventTriggerDescription: '',
mockManualExecution: true,
defaults: {
name: 'n8n Trigger',
},

View file

@ -23,6 +23,12 @@ export class Start implements INodeType {
inputs: [],
outputs: ['main'],
properties: [
{
displayName: 'This node is where a manual workflow execution starts. To make one, go back to the canvas and click execute workflow',
name: 'notice',
type: 'notice',
default: '',
},
],
};

View file

@ -53,6 +53,14 @@ export class Webhook implements INodeType {
defaults: {
name: 'Webhook',
},
triggerPanel: {
header: '',
executionsHelp: {
inactive: 'Webhooks have two modes: test and production. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the \'listen\' button, then make a request to the test URL. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. <a data-key=\"activate\">Activate</a> the workflow, then make requests to the production URL. These executions will show up in the executions list, but not in the editor.',
active: 'Webhooks have two modes: test and production. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the \'listen\' button, then make a request to the test URL. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. Since the workflow is activated, you can make requests to the production URL. These executions will show up in the <a data-key=\"executions\">executions list</a>, but not in the editor.',
},
activationHint: 'Once youve finished building your workflow, run it without having to click this button by using the production webhook URL.',
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [],
outputs: ['main'],

View file

@ -17,6 +17,7 @@ export class WorkflowTrigger implements INodeType {
version: 1,
description: 'Triggers based on various lifecycle events, like when a workflow is activated',
eventTriggerDescription: '',
mockManualExecution: true,
activationMessage: 'Your workflow will now trigger executions on the event you have defined.',
defaults: {
name: 'Workflow Trigger',

View file

@ -1180,6 +1180,22 @@ export interface INodeTypeDescription extends INodeTypeBaseDescription {
};
webhooks?: IWebhookDescription[];
translation?: { [key: string]: object };
mockManualExecution?: true;
triggerPanel?: {
header?: string;
executionsHelp?:
| string
| {
active: string;
inactive: string;
};
activationHint?:
| string
| {
active: string;
inactive: string;
};
};
}
export interface INodeHookDescription {

View file

@ -1138,7 +1138,8 @@ export function addToIssuesIfMissing(
if (
(nodeProperties.type === 'string' && (value === '' || value === undefined)) ||
(nodeProperties.type === 'multiOptions' && Array.isArray(value) && value.length === 0) ||
(nodeProperties.type === 'dateTime' && value === undefined)
(nodeProperties.type === 'dateTime' && value === undefined) ||
(nodeProperties.type === 'options' && (value === '' || value === undefined))
) {
// Parameter is requried but empty
if (foundIssues.parameters === undefined) {