Add Kitemaker node (#1676)

*  Add Kitemaker node

*  Require status ID for workItem:create

* ✏️ Reword button text

*  Add credentials file

*  Implement pagination

*  Improvements

*  Remove not needed parameter

Co-authored-by: ricardo <ricardoespinoza105@gmail.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
Iván Ovejero 2021-04-30 23:00:28 +02:00 committed by GitHub
parent efd40ea7a6
commit 4cf8055224
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1257 additions and 0 deletions

View file

@ -0,0 +1,18 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class KitemakerApi implements ICredentialType {
name = 'kitemakerApi';
displayName = 'Kitemaker API';
documentationUrl = 'kitemaker';
properties = [
{
displayName: 'Personal Access Token',
name: 'personalAccessToken',
type: 'string' as NodePropertyTypes,
default: '',
},
];
}

View file

@ -0,0 +1,85 @@
import {
IExecuteFunctions,
ILoadOptionsFunctions,
} from 'n8n-core';
import {
IDataObject,
IHookFunctions,
NodeApiError,
} from 'n8n-workflow';
export async function kitemakerRequest(
this: IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions,
body: IDataObject = {},
) {
const { personalAccessToken } = this.getCredentials('kitemakerApi') as { personalAccessToken: string };
const options = {
headers: {
Authorization: `Bearer ${personalAccessToken}`,
},
method: 'POST',
body,
uri: 'https://toil.kitemaker.co/developers/graphql',
json: true,
};
const responseData = await this.helpers.request!.call(this, options);
if (responseData.errors) {
throw new NodeApiError(this.getNode(), responseData);
}
return responseData;
}
export async function kitemakerRequestAllItems(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
body: { query: string; variables: { [key: string]: string } },
) {
const resource = this.getNodeParameter('resource', 0) as 'space' | 'user' | 'workItem';
const [group, items] = getGroupAndItems(resource);
const returnAll = this.getNodeParameter('returnAll', 0, false) as boolean;
const limit = this.getNodeParameter('limit', 0, 0) as number;
const returnData: IDataObject[] = [];
let responseData;
do {
responseData = await kitemakerRequest.call(this, body);
body.variables.cursor = responseData.data[group].cursor;
returnData.push(...responseData.data[group][items]);
if (!returnAll && returnData.length > limit) {
return returnData.slice(0, limit);
}
} while (responseData.data[group].hasMore);
return returnData;
}
function getGroupAndItems(resource: 'space' | 'user' | 'workItem') {
const map: { [key: string]: { [key: string]: string } } = {
space: { group: 'organization', items: 'spaces' },
user: { group: 'organization', items: 'users' },
workItem: { group: 'workItems', items: 'workItems' },
};
return [
map[resource]['group'],
map[resource]['items'],
];
}
export function createLoadOptions(
resources: Array<{ name?: string; username?: string; title?: string; id: string }>,
): Array<{ name: string; value: string }> {
return resources.map(option => {
if (option.username) return ({ name: option.username, value: option.id });
if (option.title) return ({ name: option.title, value: option.id });
return ({ name: option.name ?? 'Unnamed', value: option.id });
});
}

View file

@ -0,0 +1,321 @@
import {
IExecuteFunctions
} from 'n8n-core';
import {
IDataObject,
ILoadOptionsFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription
} from 'n8n-workflow';
import {
organizationOperations,
spaceFields,
spaceOperations,
userFields,
userOperations,
workItemFields,
workItemOperations,
} from './descriptions';
import {
createLoadOptions,
kitemakerRequest,
kitemakerRequestAllItems,
} from './GenericFunctions';
import {
getAllSpaces,
getAllUsers,
getAllWorkItems,
getLabels,
getOrganization,
getSpaces,
getStatuses,
getUsers,
getWorkItem,
getWorkItems,
} from './queries';
import {
createWorkItem,
editWorkItem,
} from './mutations';
export class Kitemaker implements INodeType {
description: INodeTypeDescription = {
displayName: 'Kitemaker',
name: 'kitemaker',
icon: 'file:kitemaker.svg',
group: ['input'],
version: 1,
subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}',
description: 'Consume the Kitemaker GraphQL API',
defaults: {
name: 'Kitemaker',
color: '#662482',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'kitemakerApi',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{
name: 'Organization',
value: 'organization',
},
{
name: 'Space',
value: 'space',
},
{
name: 'User',
value: 'user',
},
{
name: 'Work Item',
value: 'workItem',
},
],
default: 'workItem',
required: true,
description: 'Resource to operate on.',
},
...organizationOperations,
...spaceOperations,
...spaceFields,
...userOperations,
...userFields,
...workItemOperations,
...workItemFields,
],
};
methods = {
loadOptions: {
async getLabels(this: ILoadOptionsFunctions) {
const responseData = await kitemakerRequest.call(this, { query: getLabels });
const { data: { organization: { spaces } } } = responseData;
return createLoadOptions(spaces[0].labels);
},
async getSpaces(this: ILoadOptionsFunctions) {
const responseData = await kitemakerRequest.call(this, { query: getSpaces });
const { data: { organization: { spaces } } } = responseData;
return createLoadOptions(spaces);
},
async getStatuses(this: ILoadOptionsFunctions) {
const responseData = await kitemakerRequest.call(this, { query: getStatuses });
const { data: { organization: { spaces } } } = responseData;
return createLoadOptions(spaces[0].statuses);
},
async getUsers(this: ILoadOptionsFunctions) {
const responseData = await kitemakerRequest.call(this, { query: getUsers });
const { data: { organization: { users } } } = responseData;
return createLoadOptions(users);
},
async getWorkItems(this: ILoadOptionsFunctions) {
const spaceId = this.getNodeParameter('spaceId', 0) as string;
const responseData = await kitemakerRequest.call(this, {
query: getWorkItems,
variables: { spaceId },
});
const { data: { workItems: { workItems } } } = responseData;
return createLoadOptions(workItems);
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
let responseData;
const returnData: IDataObject[] = [];
// https://github.com/kitemakerhq/docs/blob/main/kitemaker.graphql
for (let i = 0; i < items.length; i++) {
if (resource === 'organization') {
// *********************************************************************
// organization
// *********************************************************************
if (operation === 'get') {
// ----------------------------------
// organization: get
// ----------------------------------
responseData = await kitemakerRequest.call(this, {
query: getOrganization,
});
returnData.push(responseData.data.organization);
}
} else if (resource === 'space') {
// *********************************************************************
// space
// *********************************************************************
if (operation === 'getAll') {
// ----------------------------------
// space: getAll
// ----------------------------------
const allItems = await kitemakerRequestAllItems.call(this, {
query: getAllSpaces,
variables: {},
});
returnData.push(...allItems);
}
} else if (resource === 'user') {
// *********************************************************************
// user
// *********************************************************************
if (operation === 'getAll') {
// ----------------------------------
// user: getAll
// ----------------------------------
const allItems = await kitemakerRequestAllItems.call(this, {
query: getAllUsers,
variables: {},
});
returnData.push(...allItems);
}
} else if (resource === 'workItem') {
// *********************************************************************
// workItem
// *********************************************************************
if (operation === 'create') {
// ----------------------------------
// workItem: create
// ----------------------------------
const input = {
title: this.getNodeParameter('title', i) as string,
statusId: this.getNodeParameter('statusId', i) as string[],
};
if (!input.statusId.length) {
throw new Error('Please enter a status to set for the work item to create.');
}
const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject;
if (Object.keys(additionalFields).length) {
Object.assign(input, additionalFields);
}
responseData = await kitemakerRequest.call(this, {
query: createWorkItem,
variables: { input },
});
returnData.push(responseData.data.createWorkItem.workItem);
} else if (operation === 'get') {
// ----------------------------------
// workItem: get
// ----------------------------------
const workItemId = this.getNodeParameter('workItemId', i) as string;
responseData = await kitemakerRequest.call(this, {
query: getWorkItem,
variables: { workItemId },
});
returnData.push(responseData.data.workItem);
} else if (operation === 'getAll') {
// ----------------------------------
// workItem: getAll
// ----------------------------------
const allItems = await kitemakerRequestAllItems.call(this, {
query: getAllWorkItems,
variables: {
spaceId: this.getNodeParameter('spaceId', i) as string,
},
});
returnData.push(...allItems);
} else if (operation === 'update') {
// ----------------------------------
// workItem: update
// ----------------------------------
const input = {
id: this.getNodeParameter('workItemId', i),
};
const updateFields = this.getNodeParameter('updateFields', i) as IDataObject;
if (!Object.keys(updateFields).length) {
throw new Error('Please enter at least one field to update for the work item.');
}
Object.assign(input, updateFields);
responseData = await kitemakerRequest.call(this, {
query: editWorkItem,
variables: { input },
});
returnData.push(responseData.data.editWorkItem.workItem);
}
}
}
return [this.helpers.returnJsonArray(returnData)];
}
}

View file

@ -0,0 +1,27 @@
import {
INodeProperties,
} from 'n8n-workflow';
export const organizationOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'get',
description: 'Operation to perform.',
options: [
{
name: 'Get',
value: 'get',
description: 'Retrieve data on the logged-in user\'s organization.',
},
],
displayOptions: {
show: {
resource: [
'organization',
],
},
},
},
] as INodeProperties[];

View file

@ -0,0 +1,71 @@
import {
INodeProperties,
} from 'n8n-workflow';
export const spaceOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'getAll',
description: 'Operation to perform.',
options: [
{
name: 'Get All',
value: 'getAll',
description: 'Retrieve data on all the spaces in the<br>logged-in user\'s organization.',
},
],
displayOptions: {
show: {
resource: [
'space',
],
},
},
},
] as INodeProperties[];
export const spaceFields = [
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
default: false,
description: 'Return all results.',
displayOptions: {
show: {
resource: [
'space',
],
operation: [
'getAll',
],
},
},
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
default: 5,
description: 'The number of results to return.',
typeOptions: {
minValue: 1,
maxValue: 1000,
},
displayOptions: {
show: {
resource: [
'space',
],
operation: [
'getAll',
],
returnAll: [
false,
],
},
},
},
] as INodeProperties[];

View file

@ -0,0 +1,71 @@
import {
INodeProperties,
} from 'n8n-workflow';
export const userOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'getAll',
description: 'Operation to perform.',
options: [
{
name: 'Get All',
value: 'getAll',
description: 'Retrieve data on all the users in the<br>logged-in user\'s organization.',
},
],
displayOptions: {
show: {
resource: [
'user',
],
},
},
},
] as INodeProperties[];
export const userFields = [
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
default: false,
description: 'Return all results.',
displayOptions: {
show: {
resource: [
'user',
],
operation: [
'getAll',
],
},
},
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
default: 5,
description: 'The number of results to return.',
typeOptions: {
minValue: 1,
maxValue: 1000,
},
displayOptions: {
show: {
resource: [
'user',
],
operation: [
'getAll',
],
returnAll: [
false,
],
},
},
},
] as INodeProperties[];

View file

@ -0,0 +1,372 @@
import {
INodeProperties,
} from 'n8n-workflow';
export const workItemOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'get',
description: 'Operation to perform.',
options: [
{
name: 'Create',
value: 'create',
},
{
name: 'Get',
value: 'get',
},
{
name: 'Get All',
value: 'getAll',
},
{
name: 'Update',
value: 'update',
},
],
displayOptions: {
show: {
resource: [
'workItem',
],
},
},
},
] as INodeProperties[];
export const workItemFields = [
// ----------------------------------
// workItem: create
// ----------------------------------
{
displayName: 'Title',
name: 'title',
type: 'string',
default: '',
required: true,
description: 'Title of the work item to create.',
displayOptions: {
show: {
resource: [
'workItem',
],
operation: [
'create',
],
},
},
},
{
displayName: 'Status ID',
name: 'statusId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getStatuses',
},
default: [],
required: true,
description: 'ID of the status to set on the item to create.',
displayOptions: {
show: {
resource: [
'workItem',
],
operation: [
'create',
],
},
},
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'workItem',
],
operation: [
'create',
],
},
},
options: [
{
displayName: 'Description',
name: 'description',
type: 'string',
default: '',
typeOptions: {
alwaysOpenEditWindow: true,
},
description: 'Description of the item to create. Markdown supported.',
},
{
displayName: 'Effort',
name: 'effort',
type: 'options',
default: 'SMALL',
description: 'Effort to set for the item to create.',
options: [
{
name: 'Small',
value: 'SMALL',
},
{
name: 'Medium',
value: 'MEDIUM',
},
{
name: 'Large',
value: 'LARGE',
},
],
},
{
displayName: 'Impact',
name: 'impact',
type: 'options',
default: 'SMALL',
description: 'Impact to set for the item to create.',
options: [
{
name: 'Small',
value: 'SMALL',
},
{
name: 'Medium',
value: 'MEDIUM',
},
{
name: 'Large',
value: 'LARGE',
},
],
},
{
displayName: 'Label IDs',
name: 'labelIds',
type: 'multiOptions',
typeOptions: {
loadOptionsMethod: 'getLabels',
},
default: [],
description: 'ID of the label to set on the item to create.',
},
{
displayName: 'Member IDs',
name: 'memberIds',
type: 'multiOptions',
typeOptions: {
loadOptionsMethod: 'getUsers',
},
default: [],
description: 'ID of the user to assign to the item to create.',
},
],
},
// ----------------------------------
// workItem: get
// ----------------------------------
{
displayName: 'Work Item ID',
name: 'workItemId',
type: 'string',
default: '',
required: true,
description: 'ID of the work item to retrieve.',
displayOptions: {
show: {
resource: [
'workItem',
],
operation: [
'get',
],
},
},
},
// ----------------------------------
// workItem: getAll
// ----------------------------------
{
displayName: 'Space ID',
name: 'spaceId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getSpaces',
},
default: [],
required: true,
description: 'ID of the space to retrieve the work items from.',
displayOptions: {
show: {
resource: [
'workItem',
],
operation: [
'getAll',
],
},
},
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
default: false,
description: 'Return all results.',
displayOptions: {
show: {
resource: [
'workItem',
],
operation: [
'getAll',
],
},
},
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
default: 5,
description: 'The number of results to return.',
typeOptions: {
minValue: 1,
maxValue: 1000,
},
displayOptions: {
show: {
resource: [
'workItem',
],
operation: [
'getAll',
],
returnAll: [
false,
],
},
},
},
// ----------------------------------
// workItem: update
// ----------------------------------
{
displayName: 'Work Item ID',
name: 'workItemId',
type: 'string',
default: '',
required: true,
description: 'ID of the work item to update.',
displayOptions: {
show: {
resource: [
'workItem',
],
operation: [
'update',
],
},
},
},
{
displayName: 'Update Fields',
name: 'updateFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'workItem',
],
operation: [
'update',
],
},
},
options: [
{
displayName: 'Description',
name: 'description',
type: 'string',
default: '',
typeOptions: {
alwaysOpenEditWindow: true,
},
description: 'Description of the item to update. Markdown supported.',
},
{
displayName: 'Effort',
name: 'effort',
type: 'options',
default: 'SMALL',
description: 'Effort to set for the item to update.',
options: [
{
name: 'Small',
value: 'SMALL',
},
{
name: 'Medium',
value: 'MEDIUM',
},
{
name: 'Large',
value: 'LARGE',
},
],
},
{
displayName: 'Impact',
name: 'impact',
type: 'options',
default: 'SMALL',
description: 'Impact to set for the item to update.',
options: [
{
name: 'Small',
value: 'SMALL',
},
{
name: 'Medium',
value: 'MEDIUM',
},
{
name: 'Large',
value: 'LARGE',
},
],
},
{
displayName: 'Status ID',
name: 'statusId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getStatuses',
},
default: [],
description: 'ID of the status to set on the item to update.',
},
{
displayName: 'Title',
name: 'title',
type: 'string',
default: '',
description: 'Title to set for the work item to update.',
},
],
},
] as INodeProperties[];

View file

@ -0,0 +1,4 @@
export * from './OrganizationDescription';
export * from './SpaceDescription';
export * from './UserDescription';
export * from './WorkItemDescription';

View file

@ -0,0 +1,18 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="170.000000pt" height="170.000000pt" viewBox="-30 -25 220.000000 220.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,170.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path fill="#662482" d="M1065 1445 c-302 -140 -560 -254 -572 -254 -17 -1 -61 50 -200 229
-178 229 -206 256 -242 233 -14 -9 -16 -89 -19 -793 -1 -570 1 -786 9 -796 35
-43 33 -46 852 774 558 558 787 794 787 809 0 26 -25 53 -48 52 -9 0 -264
-115 -567 -254z"/>
<path fill="#e61b73" d="M694 448 c-133 -134 -244 -251 -247 -260 -3 -9 2 -26 11 -38 16 -18
67 -26 599 -85 320 -35 589 -62 597 -59 19 7 29 38 21 62 -3 9 -159 152 -346
317 -241 212 -348 301 -367 303 -23 2 -59 -30 -268 -240z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 922 B

View file

@ -0,0 +1,69 @@
// ----------------------------------
// mutations
// ----------------------------------
export const createWorkItem = `
mutation($input: CreateWorkItemInput!) {
createWorkItem(input: $input) {
workItem {
id
number
title
description
status {
id
name
}
members {
id
username
}
watchers {
id
username
}
labels {
id
name
}
effort
impact
updatedAt
createdAt
}
}
}
`;
export const editWorkItem = `
mutation ($input: EditWorkItemInput!) {
editWorkItem(input: $input) {
workItem {
id
number
title
description
status {
id
name
}
members {
id
username
}
watchers {
id
username
}
labels {
id
name
}
effort
impact
updatedAt
createdAt
}
}
}
`;

View file

@ -0,0 +1,199 @@
// ----------------------------------
// queries
// ----------------------------------
export const getAllSpaces = `
query {
organization {
spaces {
id
name
labels {
id
name
color
}
statuses {
id
name
type
default
}
}
}
}
`;
export const getAllUsers = `
query {
organization {
users {
id
username
}
}
}
`;
export const getLabels = `
query {
organization {
spaces {
labels {
id
name
color
}
}
}
}
`;
export const getOrganization = `
query {
organization {
id
name
}
}
`;
export const getSpaces = `
query {
organization {
spaces {
id
name
labels {
id
name
color
}
statuses {
id
name
type
default
}
}
}
}
`;
export const getStatuses = `
query {
organization {
spaces {
statuses {
id
name
type
default
}
}
}
}
`;
export const getUsers = `
query {
organization {
users {
id
username
}
}
}
`;
export const getWorkItems = `
query($spaceId: ID!) {
workItems(spaceId: $spaceId) {
workItems {
id
title
}
}
}
`;
export const getWorkItem = `
query($workItemId: ID!) {
workItem(id: $workItemId) {
id
number
title
description
status {
id
name
}
sort
members {
id
username
}
watchers {
id
username
}
labels {
id
name
}
comments {
id
actor {
__typename
}
body
threadId
updatedAt
createdAt
}
effort
impact
updatedAt
createdAt
}
}
`;
export const getAllWorkItems = `
query($spaceId: ID!, $cursor: String) {
workItems(spaceId: $spaceId, cursor: $cursor) {
hasMore,
cursor,
workItems {
id
title
description
labels {
id
}
comments {
id
body
actor {
... on User {
id
username
}
... on IntegrationUser {
id
externalName
}
... on Integration {
id
type
}
... on Application {
id
name
}
}
}
}
}
}
`;

View file

@ -133,6 +133,7 @@
"dist/credentials/JotFormApi.credentials.js",
"dist/credentials/Kafka.credentials.js",
"dist/credentials/KeapOAuth2Api.credentials.js",
"dist/credentials/KitemakerApi.credentials.js",
"dist/credentials/LemlistApi.credentials.js",
"dist/credentials/LineNotifyOAuth2Api.credentials.js",
"dist/credentials/LingvaNexApi.credentials.js",
@ -406,6 +407,7 @@
"dist/nodes/Kafka/KafkaTrigger.node.js",
"dist/nodes/Keap/Keap.node.js",
"dist/nodes/Keap/KeapTrigger.node.js",
"dist/nodes/Kitemaker/Kitemaker.node.js",
"dist/nodes/Lemlist/Lemlist.node.js",
"dist/nodes/Lemlist/LemlistTrigger.node.js",
"dist/nodes/Line/Line.node.js",