🔀 Merge master

This commit is contained in:
Iván Ovejero 2021-12-15 13:01:29 +01:00
commit 121898dd45
71 changed files with 2429 additions and 863 deletions

1451
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -26,7 +26,7 @@ var nodeVersion = process.versions.node.split('.');
if (parseInt(nodeVersion[0], 10) < 14) { if (parseInt(nodeVersion[0], 10) < 14) {
console.log(`\nYour Node.js version (${process.versions.node}) is too old to run n8n.\nPlease update to version 14 or later!\n`); console.log(`\nYour Node.js version (${process.versions.node}) is too old to run n8n.\nPlease update to version 14 or later!\n`);
process.exit(0); process.exit(1);
} }
require('@oclif/command').run() require('@oclif/command').run()

View file

@ -11,6 +11,7 @@ import {
CredentialTypes, CredentialTypes,
Db, Db,
ExternalHooks, ExternalHooks,
GenericHelpers,
InternalHooksManager, InternalHooksManager,
IWorkflowBase, IWorkflowBase,
IWorkflowExecutionDataProcess, IWorkflowExecutionDataProcess,
@ -125,7 +126,8 @@ export class Execute extends Command {
await externalHooks.init(); await externalHooks.init();
const instanceId = await UserSettings.getInstanceId(); const instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId); const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli);
// Add the found types to an instance other parts of the application can use // Add the found types to an instance other parts of the application can use
const nodeTypes = NodeTypes(); const nodeTypes = NodeTypes();

View file

@ -28,6 +28,7 @@ import {
CredentialTypes, CredentialTypes,
Db, Db,
ExternalHooks, ExternalHooks,
GenericHelpers,
InternalHooksManager, InternalHooksManager,
IWorkflowDb, IWorkflowDb,
IWorkflowExecutionDataProcess, IWorkflowExecutionDataProcess,
@ -305,7 +306,8 @@ export class ExecuteBatch extends Command {
await externalHooks.init(); await externalHooks.init();
const instanceId = await UserSettings.getInstanceId(); const instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId); const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli);
// Add the found types to an instance other parts of the application can use // Add the found types to an instance other parts of the application can use
const nodeTypes = NodeTypes(); const nodeTypes = NodeTypes();

View file

@ -153,17 +153,6 @@ export class Start extends Command {
LoggerProxy.init(logger); LoggerProxy.init(logger);
logger.info('Initializing n8n process'); logger.info('Initializing n8n process');
logger.info(
'\n' +
'****************************************************\n' +
'* *\n' +
'* n8n now sends selected, anonymous telemetry. *\n' +
'* For more details (and how to opt out): *\n' +
'* https://docs.n8n.io/reference/telemetry.html *\n' +
'* *\n' +
'****************************************************\n',
);
// Start directly with the init of the database to improve startup time // Start directly with the init of the database to improve startup time
const startDbInitPromise = Db.init().catch((error: Error) => { const startDbInitPromise = Db.init().catch((error: Error) => {
logger.error(`There was an error initializing DB: "${error.message}"`); logger.error(`There was an error initializing DB: "${error.message}"`);
@ -313,7 +302,8 @@ export class Start extends Command {
} }
const instanceId = await UserSettings.getInstanceId(); const instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId); const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli);
await Server.start(); await Server.start();

View file

@ -149,7 +149,8 @@ export class Webhook extends Command {
await startDbInitPromise; await startDbInitPromise;
const instanceId = await UserSettings.getInstanceId(); const instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId); const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli);
if (config.get('executions.mode') === 'queue') { if (config.get('executions.mode') === 'queue') {
const redisHost = config.get('queue.bull.redis.host'); const redisHost = config.get('queue.bull.redis.host');

View file

@ -271,10 +271,10 @@ export class Worker extends Command {
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
Worker.jobQueue.process(flags.concurrency, async (job) => this.runJob(job, nodeTypes)); Worker.jobQueue.process(flags.concurrency, async (job) => this.runJob(job, nodeTypes));
const instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId);
const versions = await GenericHelpers.getVersions(); const versions = await GenericHelpers.getVersions();
const instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId, versions.cli);
console.info('\nn8n worker is now ready'); console.info('\nn8n worker is now ready');
console.info(` * Version: ${versions.cli}`); console.info(` * Version: ${versions.cli}`);

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n", "name": "n8n",
"version": "0.152.0", "version": "0.153.0",
"description": "n8n Workflow Automation Tool", "description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
@ -112,8 +112,8 @@
"lodash.get": "^4.4.2", "lodash.get": "^4.4.2",
"mysql2": "~2.3.0", "mysql2": "~2.3.0",
"n8n-core": "~0.96.0", "n8n-core": "~0.96.0",
"n8n-editor-ui": "~0.119.0", "n8n-editor-ui": "~0.120.0",
"n8n-nodes-base": "~0.149.0", "n8n-nodes-base": "~0.150.0",
"n8n-workflow": "~0.79.0", "n8n-workflow": "~0.79.0",
"oauth-1.0a": "^2.2.6", "oauth-1.0a": "^2.2.6",
"open": "^7.0.0", "open": "^7.0.0",

View file

@ -13,6 +13,7 @@ import {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ChildProcess } from 'child_process'; import { ChildProcess } from 'child_process';
import { stringify } from 'flatted';
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import * as PCancelable from 'p-cancelable'; import * as PCancelable from 'p-cancelable';
// eslint-disable-next-line import/no-cycle // eslint-disable-next-line import/no-cycle
@ -82,6 +83,7 @@ export class ActiveExecutions {
const execution = { const execution = {
id: executionId, id: executionId,
data: stringify(executionData.executionData!),
waitTill: null, waitTill: null,
}; };

View file

@ -314,7 +314,10 @@ export interface IDiagnosticInfo {
export interface IInternalHooksClass { export interface IInternalHooksClass {
onN8nStop(): Promise<void>; onN8nStop(): Promise<void>;
onServerStarted(diagnosticInfo: IDiagnosticInfo): Promise<unknown[]>; onServerStarted(
diagnosticInfo: IDiagnosticInfo,
firstWorkflowCreatedAt?: Date,
): Promise<unknown[]>;
onPersonalizationSurveySubmitted(answers: IPersonalizationSurveyAnswers): Promise<void>; onPersonalizationSurveySubmitted(answers: IPersonalizationSurveyAnswers): Promise<void>;
onWorkflowCreated(workflow: IWorkflowBase): Promise<void>; onWorkflowCreated(workflow: IWorkflowBase): Promise<void>;
onWorkflowDeleted(workflowId: string): Promise<void>; onWorkflowDeleted(workflowId: string): Promise<void>;
@ -404,10 +407,12 @@ export interface IN8nUISettings {
} }
export interface IPersonalizationSurveyAnswers { export interface IPersonalizationSurveyAnswers {
companySize: string | null;
codingSkill: string | null; codingSkill: string | null;
workArea: string | null; companyIndustry: string[];
companySize: string | null;
otherCompanyIndustry: string | null;
otherWorkArea: string | null; otherWorkArea: string | null;
workArea: string[] | string | null;
} }
export interface IPersonalizationSurvey { export interface IPersonalizationSurvey {

View file

@ -9,9 +9,16 @@ import {
import { Telemetry } from './telemetry'; import { Telemetry } from './telemetry';
export class InternalHooksClass implements IInternalHooksClass { export class InternalHooksClass implements IInternalHooksClass {
constructor(private telemetry: Telemetry) {} private versionCli: string;
async onServerStarted(diagnosticInfo: IDiagnosticInfo): Promise<unknown[]> { constructor(private telemetry: Telemetry, versionCli: string) {
this.versionCli = versionCli;
}
async onServerStarted(
diagnosticInfo: IDiagnosticInfo,
earliestWorkflowCreatedAt?: Date,
): Promise<unknown[]> {
const info = { const info = {
version_cli: diagnosticInfo.versionCli, version_cli: diagnosticInfo.versionCli,
db_type: diagnosticInfo.databaseType, db_type: diagnosticInfo.databaseType,
@ -25,7 +32,10 @@ export class InternalHooksClass implements IInternalHooksClass {
return Promise.all([ return Promise.all([
this.telemetry.identify(info), this.telemetry.identify(info),
this.telemetry.track('Instance started', info), this.telemetry.track('Instance started', {
...info,
earliest_workflow_created: earliestWorkflowCreatedAt,
}),
]); ]);
} }
@ -35,13 +45,17 @@ export class InternalHooksClass implements IInternalHooksClass {
coding_skill: answers.codingSkill, coding_skill: answers.codingSkill,
work_area: answers.workArea, work_area: answers.workArea,
other_work_area: answers.otherWorkArea, other_work_area: answers.otherWorkArea,
company_industry: answers.companyIndustry,
other_company_industry: answers.otherCompanyIndustry,
}); });
} }
async onWorkflowCreated(workflow: IWorkflowBase): Promise<void> { async onWorkflowCreated(workflow: IWorkflowBase): Promise<void> {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow);
return this.telemetry.track('User created workflow', { return this.telemetry.track('User created workflow', {
workflow_id: workflow.id, workflow_id: workflow.id,
node_graph: TelemetryHelpers.generateNodesGraph(workflow).nodeGraph, node_graph: nodeGraph,
node_graph_string: JSON.stringify(nodeGraph),
}); });
} }
@ -52,9 +66,13 @@ export class InternalHooksClass implements IInternalHooksClass {
} }
async onWorkflowSaved(workflow: IWorkflowBase): Promise<void> { async onWorkflowSaved(workflow: IWorkflowBase): Promise<void> {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow);
return this.telemetry.track('User saved workflow', { return this.telemetry.track('User saved workflow', {
workflow_id: workflow.id, workflow_id: workflow.id,
node_graph: TelemetryHelpers.generateNodesGraph(workflow).nodeGraph, node_graph: nodeGraph,
node_graph_string: JSON.stringify(nodeGraph),
version_cli: this.versionCli,
}); });
} }
@ -62,6 +80,7 @@ export class InternalHooksClass implements IInternalHooksClass {
const properties: IDataObject = { const properties: IDataObject = {
workflow_id: workflow.id, workflow_id: workflow.id,
is_manual: false, is_manual: false,
version_cli: this.versionCli,
}; };
if (runData !== undefined) { if (runData !== undefined) {
@ -92,6 +111,8 @@ export class InternalHooksClass implements IInternalHooksClass {
if (properties.is_manual) { if (properties.is_manual) {
const nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow); const nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow);
properties.node_graph = nodeGraphResult.nodeGraph; properties.node_graph = nodeGraphResult.nodeGraph;
properties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph);
if (errorNodeName) { if (errorNodeName) {
properties.error_node_id = nodeGraphResult.nameIndices[errorNodeName]; properties.error_node_id = nodeGraphResult.nameIndices[errorNodeName];
} }

View file

@ -13,9 +13,12 @@ export class InternalHooksManager {
throw new Error('InternalHooks not initialized'); throw new Error('InternalHooks not initialized');
} }
static init(instanceId: string): InternalHooksClass { static init(instanceId: string, versionCli: string): InternalHooksClass {
if (!this.internalHooksInstance) { if (!this.internalHooksInstance) {
this.internalHooksInstance = new InternalHooksClass(new Telemetry(instanceId)); this.internalHooksInstance = new InternalHooksClass(
new Telemetry(instanceId, versionCli),
versionCli,
);
} }
return this.internalHooksInstance; return this.internalHooksInstance;

View file

@ -2952,7 +2952,23 @@ export async function start(): Promise<void> {
deploymentType: config.get('deployment.type'), deploymentType: config.get('deployment.type'),
}; };
void InternalHooksManager.getInstance().onServerStarted(diagnosticInfo); void Db.collections
.Workflow!.findOne({
select: ['createdAt'],
order: { createdAt: 'ASC' },
})
.then(async (workflow) =>
InternalHooksManager.getInstance().onServerStarted(diagnosticInfo, workflow?.createdAt),
);
});
server.on('error', (error: Error & { code: string }) => {
if (error.code === 'EADDRINUSE') {
console.log(
`n8n's port ${PORT} is already in use. Do you have another instance of n8n running already?`,
);
process.exit(1);
}
}); });
} }

View file

@ -509,7 +509,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
this.workflowData, this.workflowData,
fullRunData, fullRunData,
this.mode, this.mode,
undefined, this.executionId,
this.retryOf, this.retryOf,
); );
} }
@ -585,7 +585,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
this.workflowData, this.workflowData,
fullRunData, fullRunData,
this.mode, this.mode,
undefined, this.executionId,
this.retryOf, this.retryOf,
); );
} }
@ -635,7 +635,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
this.workflowData, this.workflowData,
fullRunData, fullRunData,
this.mode, this.mode,
undefined, this.executionId,
this.retryOf, this.retryOf,
); );
} }
@ -676,7 +676,13 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
}); });
} }
} catch (error) { } catch (error) {
executeErrorWorkflow(this.workflowData, fullRunData, this.mode, undefined, this.retryOf); executeErrorWorkflow(
this.workflowData,
fullRunData,
this.mode,
this.executionId,
this.retryOf,
);
} }
}, },
], ],

View file

@ -31,6 +31,7 @@ import {
CredentialTypes, CredentialTypes,
Db, Db,
ExternalHooks, ExternalHooks,
GenericHelpers,
IWorkflowExecuteProcess, IWorkflowExecuteProcess,
IWorkflowExecutionDataProcessWithExecution, IWorkflowExecutionDataProcessWithExecution,
NodeTypes, NodeTypes,
@ -137,7 +138,8 @@ export class WorkflowRunnerProcess {
await externalHooks.init(); await externalHooks.init();
const instanceId = (await UserSettings.prepareUserSettings()).instanceId ?? ''; const instanceId = (await UserSettings.prepareUserSettings()).instanceId ?? '';
InternalHooksManager.init(instanceId); const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli);
// Credentials should now be loaded from database. // Credentials should now be loaded from database.
// We check if any node uses credentials. If it does, then // We check if any node uses credentials. If it does, then

View file

@ -5,28 +5,57 @@ import { IDataObject, LoggerProxy } from 'n8n-workflow';
import config = require('../../config'); import config = require('../../config');
import { getLogger } from '../Logger'; import { getLogger } from '../Logger';
interface IExecutionCountsBufferItem { type CountBufferItemKey =
manual_success_count: number; | 'manual_success_count'
manual_error_count: number; | 'manual_error_count'
prod_success_count: number; | 'prod_success_count'
prod_error_count: number; | 'prod_error_count';
}
type FirstExecutionItemKey =
| 'first_manual_success'
| 'first_manual_error'
| 'first_prod_success'
| 'first_prod_error';
type IExecutionCountsBufferItem = {
[key in CountBufferItemKey]: number;
};
interface IExecutionCountsBuffer { interface IExecutionCountsBuffer {
[workflowId: string]: IExecutionCountsBufferItem; [workflowId: string]: IExecutionCountsBufferItem;
} }
type IFirstExecutions = {
[key in FirstExecutionItemKey]: Date | undefined;
};
interface IExecutionsBuffer {
counts: IExecutionCountsBuffer;
firstExecutions: IFirstExecutions;
}
export class Telemetry { export class Telemetry {
private client?: TelemetryClient; private client?: TelemetryClient;
private instanceId: string; private instanceId: string;
private versionCli: string;
private pulseIntervalReference: NodeJS.Timeout; private pulseIntervalReference: NodeJS.Timeout;
private executionCountsBuffer: IExecutionCountsBuffer = {}; private executionCountsBuffer: IExecutionsBuffer = {
counts: {},
firstExecutions: {
first_manual_error: undefined,
first_manual_success: undefined,
first_prod_error: undefined,
first_prod_success: undefined,
},
};
constructor(instanceId: string) { constructor(instanceId: string, versionCli: string) {
this.instanceId = instanceId; this.instanceId = instanceId;
this.versionCli = versionCli;
const enabled = config.get('diagnostics.enabled') as boolean; const enabled = config.get('diagnostics.enabled') as boolean;
if (enabled) { if (enabled) {
@ -53,33 +82,41 @@ export class Telemetry {
return Promise.resolve(); return Promise.resolve();
} }
const allPromises = Object.keys(this.executionCountsBuffer).map(async (workflowId) => { const allPromises = Object.keys(this.executionCountsBuffer.counts).map(async (workflowId) => {
const promise = this.track('Workflow execution count', { const promise = this.track('Workflow execution count', {
version_cli: this.versionCli,
workflow_id: workflowId, workflow_id: workflowId,
...this.executionCountsBuffer[workflowId], ...this.executionCountsBuffer.counts[workflowId],
...this.executionCountsBuffer.firstExecutions,
}); });
this.executionCountsBuffer[workflowId].manual_error_count = 0;
this.executionCountsBuffer[workflowId].manual_success_count = 0; this.executionCountsBuffer.counts[workflowId].manual_error_count = 0;
this.executionCountsBuffer[workflowId].prod_error_count = 0; this.executionCountsBuffer.counts[workflowId].manual_success_count = 0;
this.executionCountsBuffer[workflowId].prod_success_count = 0; this.executionCountsBuffer.counts[workflowId].prod_error_count = 0;
this.executionCountsBuffer.counts[workflowId].prod_success_count = 0;
return promise; return promise;
}); });
allPromises.push(this.track('pulse')); allPromises.push(this.track('pulse', { version_cli: this.versionCli }));
return Promise.all(allPromises); return Promise.all(allPromises);
} }
async trackWorkflowExecution(properties: IDataObject): Promise<void> { async trackWorkflowExecution(properties: IDataObject): Promise<void> {
if (this.client) { if (this.client) {
const workflowId = properties.workflow_id as string; const workflowId = properties.workflow_id as string;
this.executionCountsBuffer[workflowId] = this.executionCountsBuffer[workflowId] ?? { this.executionCountsBuffer.counts[workflowId] = this.executionCountsBuffer.counts[
workflowId
] ?? {
manual_error_count: 0, manual_error_count: 0,
manual_success_count: 0, manual_success_count: 0,
prod_error_count: 0, prod_error_count: 0,
prod_success_count: 0, prod_success_count: 0,
}; };
let countKey: CountBufferItemKey;
let firstExecKey: FirstExecutionItemKey;
if ( if (
properties.success === false && properties.success === false &&
properties.error_node_type && properties.error_node_type &&
@ -89,15 +126,28 @@ export class Telemetry {
void this.track('Workflow execution errored', properties); void this.track('Workflow execution errored', properties);
if (properties.is_manual) { if (properties.is_manual) {
this.executionCountsBuffer[workflowId].manual_error_count++; firstExecKey = 'first_manual_error';
countKey = 'manual_error_count';
} else { } else {
this.executionCountsBuffer[workflowId].prod_error_count++; firstExecKey = 'first_prod_error';
countKey = 'prod_error_count';
} }
} else if (properties.is_manual) { } else if (properties.is_manual) {
this.executionCountsBuffer[workflowId].manual_success_count++; countKey = 'manual_success_count';
firstExecKey = 'first_manual_success';
} else { } else {
this.executionCountsBuffer[workflowId].prod_success_count++; countKey = 'prod_success_count';
firstExecKey = 'first_prod_success';
} }
if (
!this.executionCountsBuffer.firstExecutions[firstExecKey] &&
this.executionCountsBuffer.counts[workflowId][countKey] === 0
) {
this.executionCountsBuffer.firstExecutions[firstExecKey] = new Date();
}
this.executionCountsBuffer.counts[workflowId][countKey]++;
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-design-system", "name": "n8n-design-system",
"version": "0.8.0", "version": "0.9.0",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
"author": { "author": {

View file

@ -13,7 +13,7 @@ export default {
color: { color: {
control: { control: {
type: 'select', type: 'select',
options: ['primary', 'text-dark', 'text-base', 'text-light'], options: ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'],
}, },
}, },
}, },

View file

@ -23,7 +23,7 @@ export default {
}, },
color: { color: {
type: String, type: String,
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light'].includes(value), validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'].includes(value),
}, },
}, },
methods: { methods: {

View file

@ -0,0 +1,27 @@
import N8nSquareButton from './SquareButton.vue';
import { action } from '@storybook/addon-actions';
export default {
title: 'Atoms/SquareButton',
component: N8nSquareButton,
argTypes: {
label: {
control: 'text',
},
},
};
const methods = {
onClick: action('click'),
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nSquareButton,
},
template: '<n8n-square-button v-bind="$props" @click="onClick"></n8n-square-button>',
methods,
});
export const SquareButton = Template.bind({});

View file

@ -0,0 +1,43 @@
<template functional>
<button :class="$style.button" @click="(e) => listeners.click && listeners.click(e)">
<span :class="$style.text" v-text="props.label" />
</button>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'n8n-square-button',
props: {
label: {
type: String,
},
},
});
</script>
<style lang="scss" module>
.button {
width: 28px;
height: 29px;
border-radius: var(--border-radius-base);
border: var(--color-background-xlight);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
.text {
color: var(--color-primary) !important;
}
}
}
.text {
font-size: var(--font-size-s);
font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-loose);
color: var(--color-background-dark);
}
</style>

View file

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

View file

@ -13,7 +13,7 @@ export default {
color: { color: {
control: { control: {
type: 'select', type: 'select',
options: ['primary', 'text-dark', 'text-base', 'text-light'], options: ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'],
}, },
}, },
}, },

View file

@ -20,7 +20,7 @@ export default Vue.extend({
}, },
color: { color: {
type: String, type: String,
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light'].includes(value), validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'].includes(value),
}, },
align: { align: {
type: String, type: String,

View file

@ -10,6 +10,7 @@ import N8nMenu from './N8nMenu';
import N8nMenuItem from './N8nMenuItem'; import N8nMenuItem from './N8nMenuItem';
import N8nSelect from './N8nSelect'; import N8nSelect from './N8nSelect';
import N8nSpinner from './N8nSpinner'; import N8nSpinner from './N8nSpinner';
import N8nSquareButton from './N8nSquareButton';
import N8nText from './N8nText'; import N8nText from './N8nText';
import N8nTooltip from './N8nTooltip'; import N8nTooltip from './N8nTooltip';
import N8nOption from './N8nOption'; import N8nOption from './N8nOption';
@ -27,6 +28,7 @@ export {
N8nMenuItem, N8nMenuItem,
N8nSelect, N8nSelect,
N8nSpinner, N8nSpinner,
N8nSquareButton,
N8nText, N8nText,
N8nTooltip, N8nTooltip,
N8nOption, N8nOption,

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-editor-ui", "name": "n8n-editor-ui",
"version": "0.119.0", "version": "0.120.0",
"description": "Workflow Editor UI for n8n", "description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
@ -26,7 +26,7 @@
}, },
"dependencies": { "dependencies": {
"@fontsource/open-sans": "^4.5.0", "@fontsource/open-sans": "^4.5.0",
"n8n-design-system": "~0.8.0", "n8n-design-system": "~0.9.0",
"timeago.js": "^4.0.2", "timeago.js": "^4.0.2",
"v-click-outside": "^3.1.2", "v-click-outside": "^3.1.2",
"vue-fragment": "^1.5.2", "vue-fragment": "^1.5.2",

View file

@ -14,14 +14,20 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue';
import Telemetry from './components/Telemetry.vue'; import Telemetry from './components/Telemetry.vue';
export default { export default Vue.extend({
name: 'App', name: 'App',
components: { components: {
Telemetry, Telemetry,
}, },
}; watch: {
'$route'(route) {
this.$telemetry.page('Editor', route.name);
},
},
});
</script> </script>
<style lang="scss"> <style lang="scss">

View file

@ -496,6 +496,21 @@ export interface IPersonalizationSurvey {
shouldShow: boolean; shouldShow: boolean;
} }
export interface IN8nPrompts {
message: string;
title: string;
showContactPrompt: boolean;
showValueSurvey: boolean;
}
export interface IN8nValueSurveyData {
[key: string]: string;
}
export interface IN8nPromptResponse {
updated: boolean;
}
export interface IN8nUISettings { export interface IN8nUISettings {
endpointWebhook: string; endpointWebhook: string;
endpointWebhookTest: string; endpointWebhookTest: string;
@ -703,6 +718,7 @@ export interface IUiState {
export interface ISettingsState { export interface ISettingsState {
settings: IN8nUISettings; settings: IN8nUISettings;
promptsData: IN8nPrompts;
} }
export interface IVersionsState { export interface IVersionsState {

View file

@ -93,3 +93,7 @@ export async function makeRestApiRequest(context: IRestApiContext, method: Metho
export async function get(baseURL: string, endpoint: string, params?: IDataObject, headers?: IDataObject) { export async function get(baseURL: string, endpoint: string, params?: IDataObject, headers?: IDataObject) {
return await request({method: 'GET', baseURL, endpoint, headers, data: params}); return await request({method: 'GET', baseURL, endpoint, headers, data: params});
} }
export async function post(baseURL: string, endpoint: string, params?: IDataObject, headers?: IDataObject) {
return await request({method: 'POST', baseURL, endpoint, headers, data: params});
}

View file

@ -1,6 +1,7 @@
import { IDataObject } from 'n8n-workflow'; import { IDataObject } from 'n8n-workflow';
import { IRestApiContext, IN8nUISettings, IPersonalizationSurveyAnswers } from '../Interface'; import { IRestApiContext, IN8nPrompts, IN8nValueSurveyData, IN8nUISettings, IPersonalizationSurveyAnswers } from '../Interface';
import { makeRestApiRequest } from './helpers'; import { makeRestApiRequest, get, post } from './helpers';
import { TEMPLATES_BASE_URL } from '@/constants';
export async function getSettings(context: IRestApiContext): Promise<IN8nUISettings> { export async function getSettings(context: IRestApiContext): Promise<IN8nUISettings> {
return await makeRestApiRequest(context, 'GET', '/settings'); return await makeRestApiRequest(context, 'GET', '/settings');
@ -10,3 +11,15 @@ export async function submitPersonalizationSurvey(context: IRestApiContext, para
await makeRestApiRequest(context, 'POST', '/user-survey', params as unknown as IDataObject); await makeRestApiRequest(context, 'POST', '/user-survey', params as unknown as IDataObject);
} }
export async function getPromptsData(instanceId: string): Promise<IN8nPrompts> {
return await get(TEMPLATES_BASE_URL, '/prompts', {}, {'n8n-instance-id': instanceId});
}
export async function submitContactInfo(instanceId: string, email: string): Promise<void> {
return await post(TEMPLATES_BASE_URL, '/prompt', { email }, {'n8n-instance-id': instanceId});
}
export async function submitValueSurvey(instanceId: string, params: IN8nValueSurveyData): Promise<IN8nPrompts> {
return await post(TEMPLATES_BASE_URL, '/value-survey', params, {'n8n-instance-id': instanceId});
}

View file

@ -0,0 +1,128 @@
<template>
<Modal
:name="modalName"
:eventBus="modalBus"
:center="true"
:closeOnPressEscape="false"
:beforeClose="closeDialog"
customClass="contact-prompt-modal"
width="460px"
>
<template slot="header">
<n8n-heading tag="h2" size="xlarge" color="text-dark">{{ title }}</n8n-heading>
</template>
<template v-slot:content>
<div :class="$style.description">
<n8n-text size="medium" color="text-base">{{ description }}</n8n-text>
</div>
<div @keyup.enter="send">
<n8n-input v-model="email" placeholder="Your email address" />
</div>
<div :class="$style.disclaimer">
<n8n-text size="small" color="text-base"
>David from our product team will get in touch personally</n8n-text
>
</div>
</template>
<template v-slot:footer>
<div :class="$style.footer">
<n8n-button label="Send" float="right" @click="send" :disabled="!isEmailValid" />
</div>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import { IN8nPromptResponse } from '@/Interface';
import { VALID_EMAIL_REGEX } from '@/constants';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import Modal from './Modal.vue';
export default mixins(workflowHelpers).extend({
components: { Modal },
name: 'ContactPromptModal',
props: ['modalName'],
data() {
return {
email: '',
modalBus: new Vue(),
};
},
computed: {
...mapGetters({
promptsData: 'settings/getPromptsData',
}),
title(): string {
if (this.promptsData && this.promptsData.title) {
return this.promptsData.title;
}
return 'Youre a power user 💪';
},
description(): string {
if (this.promptsData && this.promptsData.message) {
return this.promptsData.message;
}
return 'Your experience with n8n can help us improve — for you and our entire community.';
},
isEmailValid(): boolean {
return VALID_EMAIL_REGEX.test(String(this.email).toLowerCase());
},
},
methods: {
closeDialog(): void {
this.$telemetry.track('User closed email modal', {
instance_id: this.$store.getters.instanceId,
email: null,
});
this.$store.commit('ui/closeTopModal');
},
async send() {
if (this.isEmailValid) {
const response: IN8nPromptResponse = await this.$store.dispatch(
'settings/submitContactInfo',
this.email,
);
if (response.updated) {
this.$telemetry.track('User closed email modal', {
instance_id: this.$store.getters.instanceId,
email: this.email,
});
this.$showMessage({
title: 'Thanks!',
message: "It's people like you that help make n8n better",
type: 'success',
});
}
this.$store.commit('ui/closeTopModal');
}
},
},
});
</script>
<style lang="scss" module>
.description {
margin-bottom: var(--spacing-s);
}
.disclaimer {
margin-top: var(--spacing-4xs);
}
</style>
<style lang="scss">
.dialog-wrapper {
.contact-prompt-modal {
.el-dialog__body {
padding: 16px 24px 24px;
}
}
}
</style>

View file

@ -140,8 +140,9 @@ export default mixins(workflowHelpers).extend({
}, },
}, },
methods: { methods: {
onSaveButtonClick () { async onSaveButtonClick () {
this.saveCurrentWorkflow(undefined); const saved = await this.saveCurrentWorkflow();
if (saved) this.$store.dispatch('settings/fetchPromptsData');
}, },
onTagsEditEnable() { onTagsEditEnable() {
this.$data.appliedTagIds = this.currentWorkflowTagIds; this.$data.appliedTagIds = this.currentWorkflowTagIds;

View file

@ -452,7 +452,8 @@ export default mixins(
saveAs(blob, workflowName + '.json'); saveAs(blob, workflowName + '.json');
} else if (key === 'workflow-save') { } else if (key === 'workflow-save') {
this.saveCurrentWorkflow(undefined); const saved = await this.saveCurrentWorkflow();
if (saved) this.$store.dispatch('settings/fetchPromptsData');
} else if (key === 'workflow-duplicate') { } else if (key === 'workflow-duplicate') {
this.$store.dispatch('ui/openModal', DUPLICATE_MODAL_KEY); this.$store.dispatch('ui/openModal', DUPLICATE_MODAL_KEY);
} else if (key === 'help-about') { } else if (key === 'help-about') {

View file

@ -3,7 +3,9 @@
:direction="direction" :direction="direction"
:visible="visible" :visible="visible"
:size="width" :size="width"
:before-close="close" :before-close="beforeClose"
:modal="modal"
:wrapperClosable="wrapperClosable"
> >
<template v-slot:title> <template v-slot:title>
<slot name="header" /> <slot name="header" />
@ -23,15 +25,26 @@ export default Vue.extend({
name: { name: {
type: String, type: String,
}, },
beforeClose: {
type: Function,
},
eventBus: { eventBus: {
type: Vue, type: Vue,
}, },
direction: { direction: {
type: String, type: String,
}, },
modal: {
type: Boolean,
default: true,
},
width: { width: {
type: String, type: String,
}, },
wrapperClosable: {
type: Boolean,
default: true,
},
}, },
mounted() { mounted() {
window.addEventListener('keydown', this.onWindowKeydown); window.addEventListener('keydown', this.onWindowKeydown);

View file

@ -1,5 +1,13 @@
<template> <template>
<div> <div>
<ModalRoot :name="CONTACT_PROMPT_MODAL_KEY">
<template v-slot:default="{ modalName }">
<ContactPromptModal
:modalName="modalName"
/>
</template>
</ModalRoot>
<ModalRoot :name="CREDENTIAL_EDIT_MODAL_KEY"> <ModalRoot :name="CREDENTIAL_EDIT_MODAL_KEY">
<template v-slot="{ modalName, activeId, mode }"> <template v-slot="{ modalName, activeId, mode }">
<CredentialEdit <CredentialEdit
@ -39,6 +47,12 @@
<UpdatesPanel /> <UpdatesPanel />
</ModalRoot> </ModalRoot>
<ModalRoot :name="VALUE_SURVEY_MODAL_KEY" :keepAlive="true">
<template v-slot:default="{ active }">
<ValueSurvey :isActive="active"/>
</template>
</ModalRoot>
<ModalRoot :name="WORKFLOW_OPEN_MODAL_KEY"> <ModalRoot :name="WORKFLOW_OPEN_MODAL_KEY">
<WorkflowOpen /> <WorkflowOpen />
</ModalRoot> </ModalRoot>
@ -51,8 +65,9 @@
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from "vue";
import { CREDENTIAL_LIST_MODAL_KEY, DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, PERSONALIZATION_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY, VERSIONS_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants'; import { CONTACT_PROMPT_MODAL_KEY, CREDENTIAL_LIST_MODAL_KEY, DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, PERSONALIZATION_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY, VERSIONS_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, VALUE_SURVEY_MODAL_KEY } from '@/constants';
import ContactPromptModal from './ContactPromptModal.vue';
import CredentialEdit from "./CredentialEdit/CredentialEdit.vue"; import CredentialEdit from "./CredentialEdit/CredentialEdit.vue";
import CredentialsList from "./CredentialsList.vue"; import CredentialsList from "./CredentialsList.vue";
import CredentialsSelectModal from "./CredentialsSelectModal.vue"; import CredentialsSelectModal from "./CredentialsSelectModal.vue";
@ -61,12 +76,14 @@ import ModalRoot from "./ModalRoot.vue";
import PersonalizationModal from "./PersonalizationModal.vue"; import PersonalizationModal from "./PersonalizationModal.vue";
import TagsManager from "./TagsManager/TagsManager.vue"; import TagsManager from "./TagsManager/TagsManager.vue";
import UpdatesPanel from "./UpdatesPanel.vue"; import UpdatesPanel from "./UpdatesPanel.vue";
import ValueSurvey from "./ValueSurvey.vue";
import WorkflowSettings from "./WorkflowSettings.vue"; import WorkflowSettings from "./WorkflowSettings.vue";
import WorkflowOpen from "./WorkflowOpen.vue"; import WorkflowOpen from "./WorkflowOpen.vue";
export default Vue.extend({ export default Vue.extend({
name: "Modals", name: "Modals",
components: { components: {
ContactPromptModal,
CredentialEdit, CredentialEdit,
CredentialsList, CredentialsList,
CredentialsSelectModal, CredentialsSelectModal,
@ -75,10 +92,12 @@ export default Vue.extend({
PersonalizationModal, PersonalizationModal,
TagsManager, TagsManager,
UpdatesPanel, UpdatesPanel,
ValueSurvey,
WorkflowSettings, WorkflowSettings,
WorkflowOpen, WorkflowOpen,
}, },
data: () => ({ data: () => ({
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_LIST_MODAL_KEY, CREDENTIAL_LIST_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY,
@ -88,6 +107,7 @@ export default Vue.extend({
VERSIONS_MODAL_KEY, VERSIONS_MODAL_KEY,
WORKFLOW_OPEN_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY,
VALUE_SURVEY_MODAL_KEY,
}), }),
}); });
</script> </script>

View file

@ -128,6 +128,9 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
return `Waiting for you to create an event in ${this.nodeType && this.nodeType.displayName.replace(/Trigger/, "")}`; return `Waiting for you to create an event in ${this.nodeType && this.nodeType.displayName.replace(/Trigger/, "")}`;
} }
}, },
isPollingTypeNode (): boolean {
return !!(this.nodeType && this.nodeType.polling);
},
isExecuting (): boolean { isExecuting (): boolean {
return this.$store.getters.executingNode === this.data.name; return this.$store.getters.executingNode === this.data.name;
}, },
@ -266,7 +269,16 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
return !!(this.nodeType && this.nodeType.outputs.length > 2); return !!(this.nodeType && this.nodeType.outputs.length > 2);
}, },
shouldShowTriggerTooltip () : boolean { shouldShowTriggerTooltip () : boolean {
return !!this.node && this.workflowRunning && this.workflowDataItems === 0 && this.isTriggerNode && this.isSingleActiveTriggerNode && !this.isTriggerNodeTooltipEmpty && !this.isNodeDisabled && !this.hasIssues && !this.dragging; return !!this.node &&
this.isTriggerNode &&
!this.isPollingTypeNode &&
!this.isNodeDisabled &&
this.workflowRunning &&
this.workflowDataItems === 0 &&
this.isSingleActiveTriggerNode &&
!this.isTriggerNodeTooltipEmpty &&
!this.hasIssues &&
!this.dragging;
}, },
}, },
watch: { watch: {
@ -478,7 +490,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
top: -25px; top: -25px;
left: -10px; left: -10px;
width: 120px; width: 120px;
height: 24px; height: 26px;
font-size: 0.9em; font-size: 0.9em;
text-align: left; text-align: left;
z-index: 10; z-index: 10;

View file

@ -0,0 +1,283 @@
<template>
<ModalDrawer
:name="VALUE_SURVEY_MODAL_KEY"
:beforeClose="closeDialog"
:modal="false"
:wrapperClosable="false"
direction="btt"
width="120px"
class="value-survey"
>
<template slot="header">
<div :class="$style.title">
<n8n-heading tag="h2" size="medium" color="text-xlight">{{ getTitle }}</n8n-heading>
</div>
</template>
<template slot="content">
<section :class="$style.content">
<div v-if="showButtons" :class="$style.wrapper">
<div :class="$style.buttons">
<div v-for="value in 11" :key="value - 1" :class="$style.container">
<n8n-square-button
:label="(value - 1).toString()"
@click="selectSurveyValue((value - 1).toString())"
/>
</div>
</div>
<div :class="$style.text">
<n8n-text size="small" color="text-xlight">Not likely</n8n-text>
<n8n-text size="small" color="text-xlight">Very likely</n8n-text>
</div>
</div>
<div v-else :class="$style.email">
<div :class="$style.input" @keyup.enter="send">
<n8n-input
v-model="form.email"
placeholder="Your email address"
size="medium"
@input="onInputChange"
/>
<div :class="$style.button">
<n8n-button label="Send" float="right" @click="send" :disabled="!isEmailValid" />
</div>
</div>
<div :class="$style.disclaimer">
<n8n-text size="small" color="text-xlight">
David from our product team will get in touch personally
</n8n-text>
</div>
</div>
</section>
</template>
</ModalDrawer>
</template>
<script lang="ts">
import { VALID_EMAIL_REGEX, VALUE_SURVEY_MODAL_KEY } from '@/constants';
import { IN8nPromptResponse } from '@/Interface';
import ModalDrawer from './ModalDrawer.vue';
import mixins from 'vue-typed-mixins';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
const DEFAULT_TITLE = `How likely are you to recommend n8n to a friend or colleague?`;
const GREAT_FEEDBACK_TITLE = `Great to hear! Can we reach out to see how we can make n8n even better for you?`;
const DEFAULT_FEEDBACK_TITLE = `Thanks for your feedback! We'd love to understand how we can improve. Can we reach out?`;
export default mixins(workflowHelpers).extend({
name: 'ValueSurvey',
props: ['isActive'],
components: {
ModalDrawer,
},
watch: {
isActive(isActive) {
if (isActive) {
this.$telemetry.track('User shown value survey', {
instance_id: this.$store.getters.instanceId,
});
}
},
},
computed: {
getTitle(): string {
if (this.form.value !== '') {
if (Number(this.form.value) > 7) {
return GREAT_FEEDBACK_TITLE;
} else {
return DEFAULT_FEEDBACK_TITLE;
}
} else {
return DEFAULT_TITLE;
}
},
isEmailValid(): boolean {
return VALID_EMAIL_REGEX.test(String(this.form.email).toLowerCase());
},
},
data() {
return {
form: {
email: '',
value: '',
},
showButtons: true,
VALUE_SURVEY_MODAL_KEY,
};
},
methods: {
closeDialog(): void {
if (this.form.value === '') {
this.$telemetry.track('User responded value survey score', {
instance_id: this.$store.getters.instanceId,
nps: '',
});
} else {
this.$telemetry.track('User responded value survey email', {
instance_id: this.$store.getters.instanceId,
email: '',
});
}
this.$store.commit('ui/closeTopModal');
},
onInputChange(value: string) {
this.form.email = value;
},
async selectSurveyValue(value: string) {
this.form.value = value;
this.showButtons = false;
const response: IN8nPromptResponse = await this.$store.dispatch(
'settings/submitValueSurvey',
{ value: this.form.value },
);
if (response.updated) {
this.$telemetry.track('User responded value survey score', {
instance_id: this.$store.getters.instanceId,
nps: this.form.value,
});
}
},
async send() {
if (this.isEmailValid) {
const response: IN8nPromptResponse = await this.$store.dispatch(
'settings/submitValueSurvey',
{
email: this.form.email,
value: this.form.value,
},
);
if (response.updated) {
this.$telemetry.track('User responded value survey email', {
instance_id: this.$store.getters.instanceId,
email: this.form.email,
});
this.$showMessage({
title: 'Thanks for your feedback',
message: `If youd like to help even more, answer this <a target="_blank" href="https://n8n-community.typeform.com/quicksurvey#nps=${this.form.value}&instance_id=${this.$store.getters.instanceId}">quick survey.</a>`,
type: 'success',
duration: 15000,
});
}
setTimeout(() => {
this.form.value = '';
this.form.email = '';
this.showButtons = true;
}, 1000);
this.$store.commit('ui/closeTopModal');
}
},
},
});
</script>
<style module lang="scss">
.title {
height: 16px;
text-align: center;
@media (max-width: $--breakpoint-xs) {
margin-top: 10px;
padding: 0 15px;
}
}
.content {
display: flex;
justify-content: center;
@media (max-width: $--breakpoint-xs) {
margin-top: 20px;
}
}
.wrapper {
display: flex;
flex-direction: column;
}
.buttons {
display: flex;
}
.container {
margin: 0 8px;
@media (max-width: $--breakpoint-xs) {
margin: 0 4px;
}
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
}
.text {
margin-top: 8px;
display: flex;
justify-content: space-between;
}
.input {
display: flex;
align-items: center;
}
.button {
margin-left: 10px;
}
.disclaimer {
margin-top: var(--spacing-4xs);
}
</style>
<style lang="scss">
.value-survey {
height: 120px;
top: auto;
@media (max-width: $--breakpoint-xs) {
height: 140px;
}
.el-drawer {
background: var(--color-background-dark);
@media (max-width: $--breakpoint-xs) {
height: 140px !important;
}
&__header {
height: 50px;
margin: 0;
padding: 18px 0 16px;
.el-drawer__close-btn {
top: 12px;
right: 16px;
position: absolute;
@media (max-width: $--breakpoint-xs) {
top: 2px;
right: 2px;
}
}
.el-dialog__close {
font-weight: var(--font-weight-bold);
color: var(--color-text-xlight);
}
}
}
}
</style>

View file

@ -161,6 +161,7 @@ export default mixins(
this.$emit('workflowActiveChanged', { id: this.workflowId, active: newActiveState }); this.$emit('workflowActiveChanged', { id: this.workflowId, active: newActiveState });
this.loading = false; this.loading = false;
this.$store.dispatch('settings/fetchPromptsData');
}, },
async displayActivationError () { async displayActivationError () {
let errorMessage: string; let errorMessage: string;

View file

@ -26,6 +26,8 @@ export const CREDENTIAL_EDIT_MODAL_KEY = 'editCredential';
export const CREDENTIAL_SELECT_MODAL_KEY = 'selectCredential'; export const CREDENTIAL_SELECT_MODAL_KEY = 'selectCredential';
export const CREDENTIAL_LIST_MODAL_KEY = 'credentialsList'; export const CREDENTIAL_LIST_MODAL_KEY = 'credentialsList';
export const PERSONALIZATION_MODAL_KEY = 'personalization'; export const PERSONALIZATION_MODAL_KEY = 'personalization';
export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
export const VALUE_SURVEY_MODAL_KEY = 'valueSurvey';
// breakpoints // breakpoints
export const BREAKPOINT_SM = 768; export const BREAKPOINT_SM = 768;
@ -132,4 +134,5 @@ export const CODING_SKILL_KEY = 'codingSkill';
export const OTHER_WORK_AREA_KEY = 'otherWorkArea'; export const OTHER_WORK_AREA_KEY = 'otherWorkArea';
export const OTHER_COMPANY_INDUSTRY_KEY = 'otherCompanyIndustry'; export const OTHER_COMPANY_INDUSTRY_KEY = 'otherCompanyIndustry';
export const VALID_EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; export const VALID_EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

View file

@ -6,7 +6,17 @@ export function getPersonalizedNodeTypes(answers: IPersonalizationSurveyAnswers)
const companySize = answers[COMPANY_SIZE_KEY]; const companySize = answers[COMPANY_SIZE_KEY];
const workArea = answers[WORK_AREA_KEY]; const workArea = answers[WORK_AREA_KEY];
if (companySize === null && workArea === null && answers[CODING_SKILL_KEY] === null) { function isWorkAreaAnswer(name: string) {
if (Array.isArray(workArea)) {
return workArea.includes(name);
} else {
return workArea === name;
}
}
const workAreaIsEmpty = workArea === null || workArea.length === 0;
if (companySize === null && workAreaIsEmpty && answers[CODING_SKILL_KEY] === null) {
return []; return [];
} }
@ -17,7 +27,7 @@ export function getPersonalizedNodeTypes(answers: IPersonalizationSurveyAnswers)
} }
let nodeTypes = [] as string[]; let nodeTypes = [] as string[];
if (workArea === IT_ENGINEERING_WORK_AREA) { if (isWorkAreaAnswer(IT_ENGINEERING_WORK_AREA)) {
nodeTypes = nodeTypes.concat(WEBHOOK_NODE_TYPE); nodeTypes = nodeTypes.concat(WEBHOOK_NODE_TYPE);
} }
else { else {
@ -39,16 +49,16 @@ export function getPersonalizedNodeTypes(answers: IPersonalizationSurveyAnswers)
} }
if (companySize === COMPANY_SIZE_500_999 || companySize === COMPANY_SIZE_1000_OR_MORE) { if (companySize === COMPANY_SIZE_500_999 || companySize === COMPANY_SIZE_1000_OR_MORE) {
if (workArea === SALES_BUSINESSDEV_WORK_AREA) { if (isWorkAreaAnswer(SALES_BUSINESSDEV_WORK_AREA)) {
nodeTypes = nodeTypes.concat(SALESFORCE_NODE_TYPE); nodeTypes = nodeTypes.concat(SALESFORCE_NODE_TYPE);
} }
else if (workArea === SECURITY_WORK_AREA) { else if (isWorkAreaAnswer(SECURITY_WORK_AREA)) {
nodeTypes = nodeTypes.concat([ELASTIC_SECURITY_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]); nodeTypes = nodeTypes.concat([ELASTIC_SECURITY_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
} }
else if (workArea === PRODUCT_WORK_AREA) { else if (isWorkAreaAnswer(PRODUCT_WORK_AREA)) {
nodeTypes = nodeTypes.concat([JIRA_TRIGGER_NODE_TYPE, SEGMENT_NODE_TYPE]); nodeTypes = nodeTypes.concat([JIRA_TRIGGER_NODE_TYPE, SEGMENT_NODE_TYPE]);
} }
else if (workArea === IT_ENGINEERING_WORK_AREA) { else if (isWorkAreaAnswer(IT_ENGINEERING_WORK_AREA)) {
nodeTypes = nodeTypes.concat([GITHUB_TRIGGER_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]); nodeTypes = nodeTypes.concat([GITHUB_TRIGGER_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
} }
else { else {
@ -56,19 +66,19 @@ export function getPersonalizedNodeTypes(answers: IPersonalizationSurveyAnswers)
} }
} }
else { else {
if (workArea === SALES_BUSINESSDEV_WORK_AREA) { if (isWorkAreaAnswer(SALES_BUSINESSDEV_WORK_AREA)) {
nodeTypes = nodeTypes.concat(CLEARBIT_NODE_TYPE); nodeTypes = nodeTypes.concat(CLEARBIT_NODE_TYPE);
} }
else if (workArea === SECURITY_WORK_AREA) { else if (isWorkAreaAnswer(SECURITY_WORK_AREA)) {
nodeTypes = nodeTypes.concat([PAGERDUTY_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]); nodeTypes = nodeTypes.concat([PAGERDUTY_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
} }
else if (workArea === PRODUCT_WORK_AREA) { else if (isWorkAreaAnswer(PRODUCT_WORK_AREA)) {
nodeTypes = nodeTypes.concat([JIRA_TRIGGER_NODE_TYPE, CALENDLY_TRIGGER_NODE_TYPE]); nodeTypes = nodeTypes.concat([JIRA_TRIGGER_NODE_TYPE, CALENDLY_TRIGGER_NODE_TYPE]);
} }
else if (workArea === IT_ENGINEERING_WORK_AREA) { else if (isWorkAreaAnswer(IT_ENGINEERING_WORK_AREA)) {
nodeTypes = nodeTypes.concat([EXECUTE_COMMAND_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]); nodeTypes = nodeTypes.concat([EXECUTE_COMMAND_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
} }
else if (workArea === FINANCE_WORK_AREA) { else if (isWorkAreaAnswer(FINANCE_WORK_AREA)) {
nodeTypes = nodeTypes.concat([XERO_NODE_TYPE, QUICKBOOKS_NODE_TYPE, SPREADSHEET_FILE_NODE_TYPE]); nodeTypes = nodeTypes.concat([XERO_NODE_TYPE, QUICKBOOKS_NODE_TYPE, SPREADSHEET_FILE_NODE_TYPE]);
} }
else { else {

View file

@ -1,19 +1,22 @@
import { ActionContext, Module } from 'vuex'; import { ActionContext, Module } from 'vuex';
import { import {
IN8nPrompts,
IN8nUISettings, IN8nUISettings,
IN8nValueSurveyData,
IPersonalizationSurveyAnswers, IPersonalizationSurveyAnswers,
IRootState, IRootState,
ISettingsState, ISettingsState,
} from '../Interface'; } from '../Interface';
import { getSettings, submitPersonalizationSurvey } from '../api/settings'; import { getPromptsData, getSettings, submitValueSurvey, submitPersonalizationSurvey, submitContactInfo } from '../api/settings';
import Vue from 'vue'; import Vue from 'vue';
import { getPersonalizedNodeTypes } from './helper'; import { getPersonalizedNodeTypes } from './helper';
import { PERSONALIZATION_MODAL_KEY } from '@/constants'; import { CONTACT_PROMPT_MODAL_KEY, PERSONALIZATION_MODAL_KEY, VALUE_SURVEY_MODAL_KEY } from '@/constants';
const module: Module<ISettingsState, IRootState> = { const module: Module<ISettingsState, IRootState> = {
namespaced: true, namespaced: true,
state: { state: {
settings: {} as IN8nUISettings, settings: {} as IN8nUISettings,
promptsData: {} as IN8nPrompts,
}, },
getters: { getters: {
personalizedNodeTypes(state: ISettingsState): string[] { personalizedNodeTypes(state: ISettingsState): string[] {
@ -24,6 +27,9 @@ const module: Module<ISettingsState, IRootState> = {
return getPersonalizedNodeTypes(answers); return getPersonalizedNodeTypes(answers);
}, },
getPromptsData(state: ISettingsState) {
return state.promptsData;
},
}, },
mutations: { mutations: {
setSettings(state: ISettingsState, settings: IN8nUISettings) { setSettings(state: ISettingsState, settings: IN8nUISettings) {
@ -35,6 +41,9 @@ const module: Module<ISettingsState, IRootState> = {
shouldShow: false, shouldShow: false,
}); });
}, },
setPromptsData(state: ISettingsState, promptsData: IN8nPrompts) {
Vue.set(state, 'promptsData', promptsData);
},
}, },
actions: { actions: {
async getSettings(context: ActionContext<ISettingsState, IRootState>) { async getSettings(context: ActionContext<ISettingsState, IRootState>) {
@ -71,6 +80,40 @@ const module: Module<ISettingsState, IRootState> = {
context.commit('setPersonalizationAnswers', results); context.commit('setPersonalizationAnswers', results);
}, },
async fetchPromptsData(context: ActionContext<ISettingsState, IRootState>) {
if (!context.rootGetters.isTelemetryEnabled) {
return;
}
try {
const promptsData: IN8nPrompts = await getPromptsData(context.state.settings.instanceId);
if (promptsData && promptsData.showContactPrompt) {
context.commit('ui/openModal', CONTACT_PROMPT_MODAL_KEY, {root: true});
} else if (promptsData && promptsData.showValueSurvey) {
context.commit('ui/openModal', VALUE_SURVEY_MODAL_KEY, {root: true});
}
context.commit('setPromptsData', promptsData);
} catch (e) {
return e;
}
},
async submitContactInfo(context: ActionContext<ISettingsState, IRootState>, email: string) {
try {
return await submitContactInfo(context.state.settings.instanceId, email);
} catch (e) {
return e;
}
},
async submitValueSurvey(context: ActionContext<ISettingsState, IRootState>, params: IN8nValueSurveyData) {
try {
return await submitValueSurvey(context.state.settings.instanceId, params);
} catch (e) {
return e;
}
},
}, },
}; };

View file

@ -1,4 +1,4 @@
import { CREDENTIAL_EDIT_MODAL_KEY, DUPLICATE_MODAL_KEY, PERSONALIZATION_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VERSIONS_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, CREDENTIAL_LIST_MODAL_KEY } from '@/constants'; import { CONTACT_PROMPT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, DUPLICATE_MODAL_KEY, PERSONALIZATION_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VERSIONS_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, CREDENTIAL_LIST_MODAL_KEY, VALUE_SURVEY_MODAL_KEY } from '@/constants';
import Vue from 'vue'; import Vue from 'vue';
import { ActionContext, Module } from 'vuex'; import { ActionContext, Module } from 'vuex';
import { import {
@ -10,6 +10,9 @@ const module: Module<IUiState, IRootState> = {
namespaced: true, namespaced: true,
state: { state: {
modals: { modals: {
[CONTACT_PROMPT_MODAL_KEY]: {
open: false,
},
[CREDENTIAL_EDIT_MODAL_KEY]: { [CREDENTIAL_EDIT_MODAL_KEY]: {
open: false, open: false,
mode: '', mode: '',
@ -33,6 +36,9 @@ const module: Module<IUiState, IRootState> = {
[WORKFLOW_OPEN_MODAL_KEY]: { [WORKFLOW_OPEN_MODAL_KEY]: {
open: false, open: false,
}, },
[VALUE_SURVEY_MODAL_KEY]: {
open: false,
},
[VERSIONS_MODAL_KEY]: { [VERSIONS_MODAL_KEY]: {
open: false, open: false,
}, },

View file

@ -55,6 +55,7 @@ import {
N8nMenuItem, N8nMenuItem,
N8nSelect, N8nSelect,
N8nSpinner, N8nSpinner,
N8nSquareButton,
N8nText, N8nText,
N8nTooltip, N8nTooltip,
N8nOption, N8nOption,
@ -75,6 +76,7 @@ Vue.use(N8nMenu);
Vue.use(N8nMenuItem); Vue.use(N8nMenuItem);
Vue.use(N8nSelect); Vue.use(N8nSelect);
Vue.use(N8nSpinner); Vue.use(N8nSpinner);
Vue.component('n8n-square-button', N8nSquareButton);
Vue.component('n8n-text', N8nText); Vue.component('n8n-text', N8nText);
Vue.use(N8nTooltip); Vue.use(N8nTooltip);
Vue.use(N8nOption); Vue.use(N8nOption);

View file

@ -66,6 +66,12 @@ class Telemetry {
} }
} }
page(category?: string, name?: string | undefined | null) {
if (this.telemetry) {
this.telemetry.page(category, name);
}
}
trackNodesPanel(event: string, properties: IDataObject = {}) { trackNodesPanel(event: string, properties: IDataObject = {}) {
if (this.telemetry) { if (this.telemetry) {
properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId; properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId;

View file

@ -656,6 +656,10 @@ export const store = new Vuex.Store({
return state.workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID; return state.workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID;
}, },
isTelemetryEnabled: (state) => {
return state.telemetry && state.telemetry.enabled;
},
currentWorkflowHasWebhookNode: (state: IRootState): boolean => { currentWorkflowHasWebhookNode: (state: IRootState): boolean => {
return !!state.workflow.nodes.find((node: INodeUi) => !!node.webhookId); return !!state.workflow.nodes.find((node: INodeUi) => !!node.webhookId);
}, },

View file

@ -755,7 +755,7 @@ export default mixins(
return; return;
} }
this.callDebounced('saveCurrentWorkflow', 1000, undefined, true); this.callDebounced('onSaveKeyboardShortcut', 1000);
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
// Activate the last selected node // Activate the last selected node
const lastSelectedNode = this.lastSelectedNode; const lastSelectedNode = this.lastSelectedNode;
@ -954,15 +954,22 @@ export default mixins(
}, },
cutSelectedNodes () { cutSelectedNodes () {
this.copySelectedNodes(); this.copySelectedNodes(true);
this.deleteSelectedNodes(); this.deleteSelectedNodes();
}, },
copySelectedNodes () { copySelectedNodes (isCut: boolean) {
this.getSelectedNodesToSave().then((data) => { this.getSelectedNodesToSave().then((data) => {
const nodeData = JSON.stringify(data, null, 2); const nodeData = JSON.stringify(data, null, 2);
this.copyToClipboard(nodeData); this.copyToClipboard(nodeData);
if (data.nodes.length > 0) { if (data.nodes.length > 0) {
if(!isCut){
this.$showMessage({
title: 'Copied!',
message: '',
type: 'success',
});
}
this.$telemetry.track('User copied nodes', { this.$telemetry.track('User copied nodes', {
node_types: data.nodes.map((node) => node.type), node_types: data.nodes.map((node) => node.type),
workflow_id: this.$store.getters.workflowId, workflow_id: this.$store.getters.workflowId,
@ -2732,6 +2739,7 @@ export default mixins(
}); });
this.$externalHooks().run('nodeView.mount'); this.$externalHooks().run('nodeView.mount');
this.$telemetry.page('Editor', this.$route.name);
}, },
destroyed () { destroyed () {

View file

@ -0,0 +1,18 @@
import {
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class FigmaApi implements ICredentialType {
name = 'figmaApi';
displayName = 'Figma API';
documentationUrl = 'figma';
properties: INodeProperties[] = [
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string',
default: '',
},
];
}

View file

@ -4,7 +4,12 @@ import {
} from 'n8n-workflow'; } from 'n8n-workflow';
const scopes = [ const scopes = [
'contacts', 'crm.objects.contacts.read',
'crm.schemas.contacts.read',
'crm.objects.companies.read',
'crm.schemas.companies.read',
'crm.objects.deals.read',
'crm.schemas.deals.read',
]; ];
export class HubspotDeveloperApi implements ICredentialType { export class HubspotDeveloperApi implements ICredentialType {

View file

@ -4,7 +4,16 @@ import {
} from 'n8n-workflow'; } from 'n8n-workflow';
const scopes = [ const scopes = [
'contacts', 'crm.schemas.deals.read',
'crm.objects.owners.read',
'crm.objects.contacts.write',
'crm.objects.companies.write',
'crm.objects.companies.read',
'crm.objects.deals.read',
'crm.schemas.contacts.read',
'crm.objects.deals.write',
'crm.objects.contacts.read',
'crm.schemas.companies.read',
'forms', 'forms',
'tickets', 'tickets',
]; ];

View file

@ -33,7 +33,7 @@ export class PagerDutyOAuth2Api implements ICredentialType {
displayName: 'Scope', displayName: 'Scope',
name: 'scope', name: 'scope',
type: 'hidden', type: 'hidden',
default: '', default: 'write',
}, },
{ {
displayName: 'Authentication', displayName: 'Authentication',

View file

@ -0,0 +1,24 @@
import {
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class WorkableApi implements ICredentialType {
name = 'workableApi';
displayName = 'Workable API';
documentationUrl = 'workable';
properties: INodeProperties[] = [
{
displayName: 'Subdomain',
name: 'subdomain',
type: 'string',
default: '',
},
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string',
default: '',
},
];
}

View file

@ -452,7 +452,10 @@ export class ApiTemplateIo implements INodeType {
try { try {
const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean; const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean;
const options = this.getNodeParameter('options', i) as IDataObject; let options: IDataObject = {};
if (download) {
options = this.getNodeParameter('options', i) as IDataObject;
}
const qs = { const qs = {
template_id: this.getNodeParameter('imageTemplateId', i), template_id: this.getNodeParameter('imageTemplateId', i),
@ -529,7 +532,10 @@ export class ApiTemplateIo implements INodeType {
try { try {
const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean; const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean;
const options = this.getNodeParameter('options', i) as IDataObject; let options: IDataObject = {};
if (download) {
options = this.getNodeParameter('options', i) as IDataObject;
}
const qs = { const qs = {
template_id: this.getNodeParameter('pdfTemplateId', i), template_id: this.getNodeParameter('pdfTemplateId', i),

View file

@ -0,0 +1,183 @@
import {
IHookFunctions,
IWebhookFunctions,
} from 'n8n-core';
import {
IDataObject,
INodeType,
INodeTypeDescription,
IWebhookResponseData,
} from 'n8n-workflow';
import {
figmaApiRequest,
} from './GenericFunctions';
import {
snakeCase,
} from 'change-case';
import {
randomBytes,
} from 'crypto';
export class FigmaTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Figma Trigger (Beta)',
name: 'figmaTrigger',
icon: 'file:figma.svg',
group: ['trigger'],
version: 1,
subtitle: '={{$parameter["triggerOn"]}}',
description: 'Starts the workflow when Figma events occur',
defaults: {
name: 'Figma Trigger (Beta)',
color: '#29b6f6',
},
inputs: [],
outputs: ['main'],
credentials: [
{
name: 'figmaApi',
required: true,
},
],
webhooks: [
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
path: 'webhook',
},
],
properties: [
{
displayName: 'Team ID',
name: 'teamId',
type: 'string',
required: true,
default: '',
description: 'Trigger will monitor this Figma Team for changes. Team ID can be found in the URL of a Figma Team page when viewed in a web browser: figma.com/files/team/{TEAM-ID}/',
},
{
displayName: 'Trigger On',
name: 'triggerOn',
type: 'options',
options: [
{
name: 'File Commented',
value: 'fileComment',
description: 'Triggers when someone comments on a file',
},
{
name: 'File Deleted',
value: 'fileDelete',
description: 'Triggers whenever a file has been deleted. Does not trigger on all files within a folder, if the folder is deleted',
},
{
name: 'File Updated',
value: 'fileUpdate',
description: 'Triggers whenever a file saves or is deleted. This occurs whenever a file is closed or within 30 seconds after changes have been made',
},
{
name: 'File Version Updated',
value: 'fileVersionUpdate',
description: 'Triggers whenever a named version is created in the version history of a file',
},
{
name: 'Library Publish',
value: 'libraryPublish',
description: 'Triggers whenever a library file is published',
},
],
default: '',
required: true,
},
],
};
// @ts-ignore (because of request)
webhookMethods = {
default: {
async checkExists(this: IHookFunctions): Promise<boolean> {
const webhookUrl = this.getNodeWebhookUrl('default');
const webhookData = this.getWorkflowStaticData('node');
const teamId = this.getNodeParameter('teamId') as string;
const triggerOn = this.getNodeParameter('triggerOn') as string;
// Check all the webhooks which exist already if it is identical to the
// one that is supposed to get created.
const { webhooks } = await figmaApiRequest.call(this, 'GET', `/v2/teams/${teamId}/webhooks`);
for (const webhook of webhooks) {
if (webhook.endpoint === webhookUrl
&& webhook.team_id === teamId
&& webhook.event_type === snakeCase(triggerOn).toUpperCase()
&& webhook.status === 'ACTIVE') {
webhookData.webhookId = webhook.id as string;
return true;
}
}
return false;
},
async create(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
const webhookUrl = this.getNodeWebhookUrl('default');
const triggerOn = this.getNodeParameter('triggerOn') as string;
const teamId = this.getNodeParameter('teamId') as string;
const endpoint = '/v2/webhooks';
const body: IDataObject = {
event_type: snakeCase(triggerOn).toUpperCase(),
team_id: teamId,
description: `n8n-webhook:${webhookUrl}`,
endpoint: webhookUrl,
passcode: randomBytes(10).toString('hex') as string,
};
const responseData = await figmaApiRequest.call(this, 'POST', endpoint, body);
if (responseData.id === undefined) {
// Required data is missing so was not successful
return false;
}
webhookData.webhookId = responseData.id as string;
return true;
},
async delete(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
if (webhookData.webhookId !== undefined) {
const endpoint = `/v2/webhooks/${webhookData.webhookId}`;
try {
await figmaApiRequest.call(this, 'DELETE', endpoint);
} catch (error) {
return false;
}
// Remove from the static workflow data so that it is clear
// that no webhooks are registred anymore
delete webhookData.webhookId;
}
return true;
},
},
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const bodyData = this.getBodyData();
if (bodyData.event_type === 'PING') {
const res = this.getResponseObject();
res.status(200).end();
return {
noWebhookResponse: true,
};
}
return {
workflowData: [
this.helpers.returnJsonArray(bodyData),
],
};
}
}

View file

@ -0,0 +1,36 @@
import {
OptionsWithUri,
} from 'request';
import {
IExecuteFunctions,
IExecuteSingleFunctions,
IHookFunctions,
ILoadOptionsFunctions,
} from 'n8n-core';
import {
IDataObject,
NodeApiError,
} from 'n8n-workflow';
export async function figmaApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise<any> { // tslint:disable-line:no-any
const credentials = await this.getCredentials('figmaApi') as { accessToken: string };
let options: OptionsWithUri = {
headers: { 'X-FIGMA-TOKEN': credentials.accessToken },
method,
body,
uri: uri || `https://api.figma.com${resource}`,
json: true,
};
options = Object.assign({}, options, option);
if (Object.keys(options.body).length === 0) {
delete options.body;
}
try {
return await this.helpers.request!(options);
} catch (error) {
throw new NodeApiError(this.getNode(), error);
}
}

View file

@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300" width="1667" height="2500"><style type="text/css">.st0{fill:#0acf83}.st1{fill:#a259ff}.st2{fill:#f24e1e}.st3{fill:#ff7262}.st4{fill:#1abcfe}</style><title>Figma.logo</title><desc>Created using Figma</desc><path id="path0_fill" class="st0" d="M50 300c27.6 0 50-22.4 50-50v-50H50c-27.6 0-50 22.4-50 50s22.4 50 50 50z"/><path id="path1_fill" class="st1" d="M0 150c0-27.6 22.4-50 50-50h50v100H50c-27.6 0-50-22.4-50-50z"/><path id="path1_fill_1_" class="st2" d="M0 50C0 22.4 22.4 0 50 0h50v100H50C22.4 100 0 77.6 0 50z"/><path id="path2_fill" class="st3" d="M100 0h50c27.6 0 50 22.4 50 50s-22.4 50-50 50h-50V0z"/><path id="path3_fill" class="st4" d="M200 150c0 27.6-22.4 50-50 50s-50-22.4-50-50 22.4-50 50-50 50 22.4 50 50z"/></svg>

After

Width:  |  Height:  |  Size: 802 B

View file

@ -887,6 +887,43 @@ export const contactFields: INodeProperties[] = [
default: '', default: '',
description: 'A field mask to restrict which fields on each person are returned. Multiple fields can be specified by separating them with commas.', description: 'A field mask to restrict which fields on each person are returned. Multiple fields can be specified by separating them with commas.',
}, },
{
displayName: 'Use Query',
name: 'useQuery',
type: 'boolean',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'contact',
],
},
},
default: false,
description: `Whether or not to use a query to filter the results`,
},
{
displayName: 'Query',
name: 'query',
type: 'string',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'contact',
],
useQuery: [
true,
],
},
},
default: '',
description: `The plain-text query for the request. The query is used to match prefix phrases of the fields on a person. For example, a person with name "foo name" matches queries such as "f", "fo", "foo", "foo n", "nam", etc., but not "oo n".`,
},
{ {
displayName: 'RAW Data', displayName: 'RAW Data',
name: 'rawData', name: 'rawData',
@ -918,6 +955,9 @@ export const contactFields: INodeProperties[] = [
resource: [ resource: [
'contact', 'contact',
], ],
useQuery: [
false,
],
}, },
}, },
options: [ options: [

View file

@ -24,6 +24,7 @@ import {
} from './ContactDescription'; } from './ContactDescription';
import * as moment from 'moment'; import * as moment from 'moment';
import { IData } from '../Analytics/Interfaces';
export class GoogleContacts implements INodeType { export class GoogleContacts implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
@ -264,11 +265,19 @@ export class GoogleContacts implements INodeType {
responseData.contactId = responseData.resourceName.split('/')[1]; responseData.contactId = responseData.resourceName.split('/')[1];
} }
//https://developers.google.com/people/api/rest/v1/people.connections/list //https://developers.google.com/people/api/rest/v1/people.connections/list
//https://developers.google.com/people/api/rest/v1/people/searchContacts
if (operation === 'getAll') { if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i) as boolean; const returnAll = this.getNodeParameter('returnAll', i) as boolean;
const fields = this.getNodeParameter('fields', i) as string[]; const fields = this.getNodeParameter('fields', i) as string[];
const options = this.getNodeParameter('options', i) as IDataObject; const options = this.getNodeParameter('options', i, {}) as IDataObject;
const rawData = this.getNodeParameter('rawData', i) as boolean; const rawData = this.getNodeParameter('rawData', i) as boolean;
const useQuery = this.getNodeParameter('useQuery', i) as boolean;
const endpoint = (useQuery) ? ':searchContacts' : '/me/connections';
if (useQuery) {
qs.query = this.getNodeParameter('query', i) as string;
}
if (options.sortOrder) { if (options.sortOrder) {
qs.sortOrder = options.sortOrder as number; qs.sortOrder = options.sortOrder as number;
@ -280,25 +289,36 @@ export class GoogleContacts implements INodeType {
qs.personFields = (fields as string[]).join(','); qs.personFields = (fields as string[]).join(',');
} }
if (useQuery) {
qs.readMask = qs.personFields;
delete qs.personFields;
}
if (returnAll) { if (returnAll) {
responseData = await googleApiRequestAllItems.call( responseData = await googleApiRequestAllItems.call(
this, this,
'connections', (useQuery) ? 'results' : 'connections',
'GET', 'GET',
`/people/me/connections`, `/people${endpoint}`,
{}, {},
qs, qs,
); );
if (useQuery) {
responseData = responseData.map((result: IDataObject) => result.person);
}
} else { } else {
qs.pageSize = this.getNodeParameter('limit', i) as number; qs.pageSize = this.getNodeParameter('limit', i) as number;
responseData = await googleApiRequest.call( responseData = await googleApiRequest.call(
this, this,
'GET', 'GET',
`/people/me/connections`, `/people${endpoint}`,
{}, {},
qs, qs,
); );
responseData = responseData.connections;
responseData = responseData.connections || responseData.results?.map((result: IDataObject) => result.person) || [];
} }
if (!rawData) { if (!rawData) {

View file

@ -33,7 +33,10 @@ export const cameraProxyFields: INodeProperties[] = [
{ {
displayName: 'Camera Entity ID', displayName: 'Camera Entity ID',
name: 'cameraEntityId', name: 'cameraEntityId',
type: 'string', type: 'options',
typeOptions: {
loadOptionsMethod: 'getCameraEntities',
},
default: '', default: '',
required: true, required: true,
displayOptions: { displayOptions: {

View file

@ -4,15 +4,17 @@ import {
import { import {
IExecuteFunctions, IExecuteFunctions,
ILoadOptionsFunctions,
} from 'n8n-core'; } from 'n8n-core';
import { import {
IDataObject, IDataObject,
INodePropertyOptions,
NodeApiError, NodeApiError,
NodeOperationError, NodeOperationError,
} from 'n8n-workflow'; } from 'n8n-workflow';
export async function homeAssistantApiRequest(this: IExecuteFunctions, method: string, resource: string, body: IDataObject = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}) { export async function homeAssistantApiRequest(this: IExecuteFunctions | ILoadOptionsFunctions, method: string, resource: string, body: IDataObject = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}) {
const credentials = await this.getCredentials('homeAssistantApi'); const credentials = await this.getCredentials('homeAssistantApi');
if (credentials === undefined) { if (credentials === undefined) {
@ -35,8 +37,51 @@ export async function homeAssistantApiRequest(this: IExecuteFunctions, method: s
delete options.body; delete options.body;
} }
try { try {
return await this.helpers.request(options); if (this.helpers.request) {
return await this.helpers.request(options);
}
} catch (error) { } catch (error) {
throw new NodeApiError(this.getNode(), error); throw new NodeApiError(this.getNode(), error);
} }
} }
export async function getHomeAssistantEntities(this: IExecuteFunctions | ILoadOptionsFunctions, domain = '') {
const returnData: INodePropertyOptions[] = [];
const entities = await homeAssistantApiRequest.call(this, 'GET', '/states');
for (const entity of entities) {
const entityId = entity.entity_id as string;
if (domain === '' || domain && entityId.startsWith(domain)) {
const entityName = entity.attributes.friendly_name as string || entityId;
returnData.push({
name: entityName,
value: entityId,
});
}
}
return returnData;
}
export async function getHomeAssistantServices(this: IExecuteFunctions | ILoadOptionsFunctions, domain = '') {
const returnData: INodePropertyOptions[] = [];
const services = await homeAssistantApiRequest.call(this, 'GET', '/services');
if (domain === '') {
// If no domain specified return domains
const domains = services.map(({ domain }: IDataObject) => domain as string).sort();
returnData.push(...domains.map((service: string) => ({ name: service, value: service })));
return returnData;
} else {
// If we have a domain, return all relevant services
const domainServices = services.filter((service: IDataObject) => service.domain === domain);
for (const domainService of domainServices) {
for (const [serviceID, value] of Object.entries(domainService.services)) {
const serviceProperties = value as IDataObject;
const serviceName = serviceProperties.description || serviceID;
returnData.push({
name: serviceName as string,
value: serviceID,
});
}
}
}
return returnData;
}

View file

@ -6,7 +6,9 @@ import {
ICredentialsDecrypted, ICredentialsDecrypted,
ICredentialTestFunctions, ICredentialTestFunctions,
IDataObject, IDataObject,
ILoadOptionsFunctions,
INodeExecutionData, INodeExecutionData,
INodePropertyOptions,
INodeType, INodeType,
INodeTypeDescription, INodeTypeDescription,
NodeCredentialTestResult, NodeCredentialTestResult,
@ -52,6 +54,8 @@ import {
} from './CameraProxyDescription'; } from './CameraProxyDescription';
import { import {
getHomeAssistantEntities,
getHomeAssistantServices,
homeAssistantApiRequest, homeAssistantApiRequest,
} from './GenericFunctions'; } from './GenericFunctions';
@ -169,9 +173,29 @@ export class HomeAssistant implements INodeType {
status: 'OK', status: 'OK',
message: 'Authentication successful!', message: 'Authentication successful!',
}; };
}, },
}, },
loadOptions: {
async getAllEntities(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
return await getHomeAssistantEntities.call(this);
},
async getCameraEntities(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
return await getHomeAssistantEntities.call(this, 'camera');
},
async getDomains(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
return await getHomeAssistantServices.call(this);
},
async getDomainServices(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const currentDomain = this.getCurrentNodeParameter('domain') as string;
if (currentDomain) {
return await getHomeAssistantServices.call(this, currentDomain);
} else {
return [];
}
},
},
}; };
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {

View file

@ -83,7 +83,10 @@ export const serviceFields: INodeProperties[] = [
{ {
displayName: 'Domain', displayName: 'Domain',
name: 'domain', name: 'domain',
type: 'string', type: 'options',
typeOptions: {
loadOptionsMethod: 'getDomains',
},
default: '', default: '',
required: true, required: true,
displayOptions: { displayOptions: {
@ -100,7 +103,13 @@ export const serviceFields: INodeProperties[] = [
{ {
displayName: 'Service', displayName: 'Service',
name: 'service', name: 'service',
type: 'string', type: 'options',
typeOptions: {
loadOptionsDependsOn: [
'domain',
],
loadOptionsMethod: 'getDomainServices',
},
default: '', default: '',
required: true, required: true,
displayOptions: { displayOptions: {

View file

@ -43,7 +43,10 @@ export const stateFields: INodeProperties[] = [
{ {
displayName: 'Entity ID', displayName: 'Entity ID',
name: 'entityId', name: 'entityId',
type: 'string', type: 'options',
typeOptions: {
loadOptionsMethod: 'getAllEntities',
},
displayOptions: { displayOptions: {
show: { show: {
operation: [ operation: [
@ -110,7 +113,10 @@ export const stateFields: INodeProperties[] = [
{ {
displayName: 'Entity ID', displayName: 'Entity ID',
name: 'entityId', name: 'entityId',
type: 'string', type: 'options',
typeOptions: {
loadOptionsMethod: 'getAllEntities',
},
displayOptions: { displayOptions: {
show: { show: {
operation: [ operation: [

View file

@ -39,12 +39,16 @@ export async function hubspotApiRequest(this: IHookFunctions | IExecuteFunctions
return await this.helpers.request!(options); return await this.helpers.request!(options);
} else if (authenticationMethod === 'developerApi') { } else if (authenticationMethod === 'developerApi') {
const credentials = await this.getCredentials('hubspotDeveloperApi'); if (endpoint.includes('webhooks')) {
options.qs.hapikey = credentials!.apiKey as string; const credentials = await this.getCredentials('hubspotDeveloperApi');
return await this.helpers.request!(options); options.qs.hapikey = credentials!.apiKey as string;
return await this.helpers.request!(options);
} else {
return await this.helpers.requestOAuth2!.call(this, 'hubspotDeveloperApi', options, { tokenType: 'Bearer', includeCredentialsOnRefreshOnBody: true });
}
} else { } else {
// @ts-ignore
return await this.helpers.requestOAuth2!.call(this, 'hubspotOAuth2Api', options, { tokenType: 'Bearer', includeCredentialsOnRefreshOnBody: true }); return await this.helpers.requestOAuth2!.call(this, 'hubspotOAuth2Api', options, { tokenType: 'Bearer', includeCredentialsOnRefreshOnBody: true });
} }
} catch (error) { } catch (error) {

View file

@ -143,6 +143,9 @@ export class HubspotTrigger implements INodeType {
name: 'property', name: 'property',
type: 'options', type: 'options',
typeOptions: { typeOptions: {
loadOptionsDependsOn: [
'contact.propertyChange',
],
loadOptionsMethod: 'getContactProperties', loadOptionsMethod: 'getContactProperties',
}, },
displayOptions: { displayOptions: {
@ -160,6 +163,9 @@ export class HubspotTrigger implements INodeType {
name: 'property', name: 'property',
type: 'options', type: 'options',
typeOptions: { typeOptions: {
loadOptionsDependsOn: [
'company.propertyChange',
],
loadOptionsMethod: 'getCompanyProperties', loadOptionsMethod: 'getCompanyProperties',
}, },
displayOptions: { displayOptions: {
@ -177,6 +183,9 @@ export class HubspotTrigger implements INodeType {
name: 'property', name: 'property',
type: 'options', type: 'options',
typeOptions: { typeOptions: {
loadOptionsDependsOn: [
'deal.propertyChange',
],
loadOptionsMethod: 'getDealProperties', loadOptionsMethod: 'getDealProperties',
}, },
displayOptions: { displayOptions: {
@ -220,51 +229,48 @@ export class HubspotTrigger implements INodeType {
// select them easily // select them easily
async getContactProperties(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> { async getContactProperties(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = []; const returnData: INodePropertyOptions[] = [];
for (const field of contactFields) { const endpoint = '/properties/v2/contacts/properties';
const properties = await hubspotApiRequest.call(this, 'GET', endpoint, {});
for (const property of properties) {
const propertyName = property.label;
const propertyId = property.name;
returnData.push({ returnData.push({
name: capitalCase(field.label), name: propertyName,
value: field.id, value: propertyId,
}); });
} }
returnData.sort((a, b) => {
if (a.name < b.name) { return -1; }
if (a.name > b.name) { return 1; }
return 0;
});
return returnData; return returnData;
}, },
// Get all the available companies to display them to user so that he can // Get all the available companies to display them to user so that he can
// select them easily // select them easily
async getCompanyProperties(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> { async getCompanyProperties(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = []; const returnData: INodePropertyOptions[] = [];
for (const field of companyFields) { const endpoint = '/properties/v2/companies/properties';
const properties = await hubspotApiRequest.call(this, 'GET', endpoint, {});
for (const property of properties) {
const propertyName = property.label;
const propertyId = property.name;
returnData.push({ returnData.push({
name: capitalCase(field.label), name: propertyName,
value: field.id, value: propertyId,
}); });
} }
returnData.sort((a, b) => {
if (a.name < b.name) { return -1; }
if (a.name > b.name) { return 1; }
return 0;
});
return returnData; return returnData;
}, },
// Get all the available deals to display them to user so that he can // Get all the available deals to display them to user so that he can
// select them easily // select them easily
async getDealProperties(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> { async getDealProperties(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = []; const returnData: INodePropertyOptions[] = [];
for (const field of dealFields) { const endpoint = '/properties/v2/deals/properties';
const properties = await hubspotApiRequest.call(this, 'GET', endpoint, {});
for (const property of properties) {
const propertyName = property.label;
const propertyId = property.name;
returnData.push({ returnData.push({
name: capitalCase(field.label), name: propertyName,
value: field.id, value: propertyId,
}); });
} }
returnData.sort((a, b) => {
if (a.name < b.name) { return -1; }
if (a.name > b.name) { return 1; }
return 0;
});
return returnData; return returnData;
}, },
}, },

View file

@ -506,9 +506,15 @@ export class Jira implements INodeType {
} }
} }
if (additionalFields.reporter) { if (additionalFields.reporter) {
fields.reporter = { if (jiraVersion === 'server') {
id: additionalFields.reporter as string, fields.reporter = {
}; name: additionalFields.reporter as string,
};
} else {
fields.reporter = {
id: additionalFields.reporter as string,
};
}
} }
if (additionalFields.description) { if (additionalFields.description) {
fields.description = additionalFields.description as string; fields.description = additionalFields.description as string;
@ -586,9 +592,15 @@ export class Jira implements INodeType {
} }
} }
if (updateFields.reporter) { if (updateFields.reporter) {
fields.reporter = { if (jiraVersion === 'server') {
id: updateFields.reporter as string, fields.reporter = {
}; name: updateFields.reporter as string,
};
} else {
fields.reporter = {
id: updateFields.reporter as string,
};
}
} }
if (updateFields.description) { if (updateFields.description) {
fields.description = updateFields.description as string; fields.description = updateFields.description as string;

View file

@ -43,6 +43,10 @@ export class OneSimpleApi implements INodeType {
name: 'Information', name: 'Information',
value: 'information', value: 'information',
}, },
{
name: 'Social Profile',
value: 'socialProfile',
},
{ {
name: 'Utility', name: 'Utility',
value: 'utility', value: 'utility',
@ -55,7 +59,7 @@ export class OneSimpleApi implements INodeType {
default: 'website', default: 'website',
required: true, required: true,
}, },
// Generation // website
{ {
displayName: 'Operation', displayName: 'Operation',
name: 'operation', name: 'operation',
@ -86,6 +90,32 @@ export class OneSimpleApi implements INodeType {
], ],
default: 'pdf', default: 'pdf',
}, },
// socialProfile
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'socialProfile',
],
},
},
options: [
{
name: 'Instagram',
value: 'instagramProfile',
description: 'Get details about an Instagram profile',
},
{
name: 'Spotify',
value: 'spotifyArtistProfile',
description: 'Get details about a Spotify Artist',
},
],
default: 'instagramProfile',
},
// Information // Information
{ {
displayName: 'Operation', displayName: 'Operation',
@ -113,7 +143,7 @@ export class OneSimpleApi implements INodeType {
default: 'exchangeRate', default: 'exchangeRate',
description: 'The operation to perform.', description: 'The operation to perform.',
}, },
// Utiliy // Utility
{ {
displayName: 'Operation', displayName: 'Operation',
name: 'operation', name: 'operation',
@ -279,7 +309,7 @@ export class OneSimpleApi implements INodeType {
type: 'boolean', type: 'boolean',
default: false, default: false,
description: `Normally the API will reuse a previously taken screenshot of the URL to give a faster response. description: `Normally the API will reuse a previously taken screenshot of the URL to give a faster response.
This option allows you to retake the screenshot at that exact time, for those times when it's necessary`, This option allows you to retake the screenshot at that exact time, for those times when it's necessary`,
}, },
], ],
}, },
@ -508,7 +538,7 @@ export class OneSimpleApi implements INodeType {
type: 'boolean', type: 'boolean',
default: false, default: false,
description: `Normally the API will reuse a previously taken screenshot of the URL to give a faster response. description: `Normally the API will reuse a previously taken screenshot of the URL to give a faster response.
This option allows you to retake the screenshot at that exact time, for those times when it's necessary`, This option allows you to retake the screenshot at that exact time, for those times when it's necessary`,
}, },
{ {
displayName: 'Full Page', displayName: 'Full Page',
@ -519,6 +549,44 @@ export class OneSimpleApi implements INodeType {
}, },
], ],
}, },
// socialProfile: instagramProfile
{
displayName: 'Profile Name',
name: 'profileName',
type: 'string',
required: true,
displayOptions: {
show: {
operation: [
'instagramProfile',
],
resource: [
'socialProfile',
],
},
},
default: '',
description: 'Profile name to get details of',
},
// socialProfile: spotifyArtistProfile
{
displayName: 'Artist Name',
name: 'artistName',
type: 'string',
required: true,
displayOptions: {
show: {
operation: [
'spotifyArtistProfile',
],
resource: [
'socialProfile',
],
},
},
default: '',
description: 'Artist name to get details for',
},
// information: exchangeRate // information: exchangeRate
{ {
displayName: 'Value', displayName: 'Value',
@ -778,6 +846,20 @@ export class OneSimpleApi implements INodeType {
} }
} }
if (resource === 'socialProfile') {
if (operation === 'instagramProfile') {
const profileName = this.getNodeParameter('profileName', i) as string;
qs.profile = profileName;
responseData = await oneSimpleApiRequest.call(this, 'GET', '/instagram_profile', {}, qs);
}
if (operation === 'spotifyArtistProfile') {
const artistName = this.getNodeParameter('artistName', i) as string;
qs.profile = artistName;
responseData = await oneSimpleApiRequest.call(this, 'GET', '/spotify_profile', {}, qs);
}
}
if (resource === 'information') { if (resource === 'information') {
if (operation === 'exchangeRate') { if (operation === 'exchangeRate') {
const value = this.getNodeParameter('value', i) as string; const value = this.getNodeParameter('value', i) as string;
@ -865,3 +947,4 @@ export class OneSimpleApi implements INodeType {
return [this.helpers.returnJsonArray(returnData)]; return [this.helpers.returnJsonArray(returnData)];
} }
} }

View file

@ -1744,8 +1744,8 @@ export class Salesforce implements INodeType {
if (additionalFields.type !== undefined) { if (additionalFields.type !== undefined) {
body.Type = additionalFields.type as string; body.Type = additionalFields.type as string;
} }
if (additionalFields.ammount !== undefined) { if (additionalFields.amount !== undefined) {
body.Amount = additionalFields.ammount as number; body.Amount = additionalFields.amount as number;
} }
if (additionalFields.owner !== undefined) { if (additionalFields.owner !== undefined) {
body.OwnerId = additionalFields.owner as string; body.OwnerId = additionalFields.owner as string;
@ -1813,8 +1813,8 @@ export class Salesforce implements INodeType {
if (updateFields.type !== undefined) { if (updateFields.type !== undefined) {
body.Type = updateFields.type as string; body.Type = updateFields.type as string;
} }
if (updateFields.ammount !== undefined) { if (updateFields.amount !== undefined) {
body.Amount = updateFields.ammount as number; body.Amount = updateFields.amount as number;
} }
if (updateFields.owner !== undefined) { if (updateFields.owner !== undefined) {
body.OwnerId = updateFields.owner as string; body.OwnerId = updateFields.owner as string;

View file

@ -0,0 +1,37 @@
import {
OptionsWithUri,
} from 'request';
import {
IExecuteFunctions,
IExecuteSingleFunctions,
IHookFunctions,
ILoadOptionsFunctions,
} from 'n8n-core';
import {
IDataObject,
NodeApiError,
} from 'n8n-workflow';
export async function workableApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise<any> { // tslint:disable-line:no-any
const credentials = await this.getCredentials('workableApi') as { accessToken: string, subdomain: string };
let options: OptionsWithUri = {
headers: { 'Authorization': `Bearer ${credentials.accessToken}` },
method,
qs,
body,
uri: uri || `https://${credentials.subdomain}.workable.com/spi/v3${resource}`,
json: true,
};
options = Object.assign({}, options, option);
if (Object.keys(options.body).length === 0) {
delete options.body;
}
try {
return await this.helpers.request!(options);
} catch (error) {
throw new NodeApiError(this.getNode(), error);
}
}

View file

@ -0,0 +1,201 @@
import {
IHookFunctions,
IWebhookFunctions,
} from 'n8n-core';
import {
IDataObject,
ILoadOptionsFunctions,
INodePropertyOptions,
INodeType,
INodeTypeDescription,
IWebhookResponseData,
} from 'n8n-workflow';
import {
workableApiRequest,
} from './GenericFunctions';
import {
snakeCase,
} from 'change-case';
export class WorkableTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Workable Trigger',
name: 'workableTrigger',
icon: 'file:workable.png',
group: ['trigger'],
version: 1,
subtitle: '={{$parameter["triggerOn"]}}',
description: 'Starts the workflow when Workable events occur',
defaults: {
name: 'Workable Trigger',
color: '#29b6f6',
},
inputs: [],
outputs: ['main'],
credentials: [
{
name: 'workableApi',
required: true,
},
],
webhooks: [
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
path: 'webhook',
},
],
properties: [
{
displayName: 'Trigger On',
name: 'triggerOn',
type: 'options',
options: [
{
name: 'Candidate Created',
value: 'candidateCreated',
},
{
name: 'Candidate Moved',
value: 'candidateMoved',
},
],
default: '',
required: true,
},
{
displayName: 'Filters',
name: 'filters',
type: 'collection',
placeholder: 'Add Filter',
default: {},
options: [
{
displayName: 'Job',
name: 'job',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getJobs',
},
default: '',
description: `Get notifications only for one job`,
},
{
displayName: 'Stage',
name: 'stage',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getStages',
},
default: '',
description: `Get notifications for specific stages. e.g. 'hired'`,
},
],
},
],
};
methods = {
loadOptions: {
async getJobs(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { jobs } = await workableApiRequest.call(this, 'GET', '/jobs');
for (const job of jobs) {
returnData.push({
name: job.full_title,
value: job.shortcode,
});
}
return returnData;
},
async getStages(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { stages } = await workableApiRequest.call(this, 'GET', '/stages');
for (const stage of stages) {
returnData.push({
name: stage.name,
value: stage.slug,
});
}
return returnData;
},
},
};
// @ts-ignore (because of request)
webhookMethods = {
default: {
async checkExists(this: IHookFunctions): Promise<boolean> {
const webhookUrl = this.getNodeWebhookUrl('default');
const webhookData = this.getWorkflowStaticData('node');
// Check all the webhooks which exist already if it is identical to the
// one that is supposed to get created.
const { subscriptions } = await workableApiRequest.call(this, 'GET', `/subscriptions`);
for (const subscription of subscriptions) {
if (subscription.target === webhookUrl) {
webhookData.webhookId = subscription.id as string;
return true;
}
}
return false;
},
async create(this: IHookFunctions): Promise<boolean> {
const credentials = await this.getCredentials('workableApi') as { accessToken: string, subdomain: string };
const webhookData = this.getWorkflowStaticData('node');
const webhookUrl = this.getNodeWebhookUrl('default');
const triggerOn = this.getNodeParameter('triggerOn') as string;
const { stage, job } = this.getNodeParameter('filters') as IDataObject;
const endpoint = '/subscriptions';
const body: IDataObject = {
event: snakeCase(triggerOn).toLowerCase(),
args: {
account_id: credentials.subdomain,
...(job) && { job_shortcode: job },
...(stage) && { stage_slug: stage },
},
target: webhookUrl,
};
const responseData = await workableApiRequest.call(this, 'POST', endpoint, body);
if (responseData.id === undefined) {
// Required data is missing so was not successful
return false;
}
webhookData.webhookId = responseData.id as string;
return true;
},
async delete(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
if (webhookData.webhookId !== undefined) {
const endpoint = `/subscriptions/${webhookData.webhookId}`;
try {
await workableApiRequest.call(this, 'DELETE', endpoint);
} catch (error) {
return false;
}
// Remove from the static workflow data so that it is clear
// that no webhooks are registred anymore
delete webhookData.webhookId;
}
return true;
},
},
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const bodyData = this.getBodyData();
return {
workflowData: [
this.helpers.returnJsonArray(bodyData),
],
};
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-nodes-base", "name": "n8n-nodes-base",
"version": "0.149.0", "version": "0.150.0",
"description": "Base nodes of n8n", "description": "Base nodes of n8n",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
@ -89,6 +89,7 @@
"dist/credentials/EventbriteOAuth2Api.credentials.js", "dist/credentials/EventbriteOAuth2Api.credentials.js",
"dist/credentials/FacebookGraphApi.credentials.js", "dist/credentials/FacebookGraphApi.credentials.js",
"dist/credentials/FacebookGraphAppApi.credentials.js", "dist/credentials/FacebookGraphAppApi.credentials.js",
"dist/credentials/FigmaApi.credentials.js",
"dist/credentials/FileMaker.credentials.js", "dist/credentials/FileMaker.credentials.js",
"dist/credentials/FlowApi.credentials.js", "dist/credentials/FlowApi.credentials.js",
"dist/credentials/FormIoApi.credentials.js", "dist/credentials/FormIoApi.credentials.js",
@ -301,6 +302,7 @@
"dist/credentials/WiseApi.credentials.js", "dist/credentials/WiseApi.credentials.js",
"dist/credentials/WooCommerceApi.credentials.js", "dist/credentials/WooCommerceApi.credentials.js",
"dist/credentials/WordpressApi.credentials.js", "dist/credentials/WordpressApi.credentials.js",
"dist/credentials/WorkableApi.credentials.js",
"dist/credentials/WufooApi.credentials.js", "dist/credentials/WufooApi.credentials.js",
"dist/credentials/XeroOAuth2Api.credentials.js", "dist/credentials/XeroOAuth2Api.credentials.js",
"dist/credentials/YourlsApi.credentials.js", "dist/credentials/YourlsApi.credentials.js",
@ -402,6 +404,7 @@
"dist/nodes/ExecuteWorkflow/ExecuteWorkflow.node.js", "dist/nodes/ExecuteWorkflow/ExecuteWorkflow.node.js",
"dist/nodes/Facebook/FacebookGraphApi.node.js", "dist/nodes/Facebook/FacebookGraphApi.node.js",
"dist/nodes/Facebook/FacebookTrigger.node.js", "dist/nodes/Facebook/FacebookTrigger.node.js",
"dist/nodes/Figma/FigmaTrigger.node.js",
"dist/nodes/FileMaker/FileMaker.node.js", "dist/nodes/FileMaker/FileMaker.node.js",
"dist/nodes/Flow/Flow.node.js", "dist/nodes/Flow/Flow.node.js",
"dist/nodes/Flow/FlowTrigger.node.js", "dist/nodes/Flow/FlowTrigger.node.js",
@ -637,6 +640,7 @@
"dist/nodes/WooCommerce/WooCommerce.node.js", "dist/nodes/WooCommerce/WooCommerce.node.js",
"dist/nodes/WooCommerce/WooCommerceTrigger.node.js", "dist/nodes/WooCommerce/WooCommerceTrigger.node.js",
"dist/nodes/Wordpress/Wordpress.node.js", "dist/nodes/Wordpress/Wordpress.node.js",
"dist/nodes/Workable/WorkableTrigger.node.js",
"dist/nodes/WorkflowTrigger/WorkflowTrigger.node.js", "dist/nodes/WorkflowTrigger/WorkflowTrigger.node.js",
"dist/nodes/WriteBinaryFile/WriteBinaryFile.node.js", "dist/nodes/WriteBinaryFile/WriteBinaryFile.node.js",
"dist/nodes/Wufoo/WufooTrigger.node.js", "dist/nodes/Wufoo/WufooTrigger.node.js",