From cb476069bd00ee519dcb6eee2401b19eb9ca1c7b Mon Sep 17 00:00:00 2001 From: ricardo Date: Fri, 1 May 2020 19:46:02 -0400 Subject: [PATCH 1/2] :zap: Improvements --- .../SurveyMonkeyApi.credentials.ts | 11 +- .../nodes/Asana/GenericFunctions.ts | 2 +- .../nodes/SurveyMonkey/GenericFunctions.ts | 4 +- .../nodes/SurveyMonkey/Interfaces.ts | 47 +++ .../SurveyMonkey/SurveyMonkeyTrigger.node.ts | 316 ++++++++++++++++-- 5 files changed, 349 insertions(+), 31 deletions(-) create mode 100644 packages/nodes-base/nodes/SurveyMonkey/Interfaces.ts diff --git a/packages/nodes-base/credentials/SurveyMonkeyApi.credentials.ts b/packages/nodes-base/credentials/SurveyMonkeyApi.credentials.ts index 130690d423..66614595eb 100644 --- a/packages/nodes-base/credentials/SurveyMonkeyApi.credentials.ts +++ b/packages/nodes-base/credentials/SurveyMonkeyApi.credentials.ts @@ -12,11 +12,12 @@ export class SurveyMonkeyApi implements ICredentialType { name: 'accessToken', type: 'string' as NodePropertyTypes, default: '', - description: `The access token must have the following scopes:
- - Create/modify webhooks
- - View webhooks
- - View surveys
- - View collectors
+ description: `The access token must have the following scopes:
+ - Create/modify webhooks
+ - View webhooks
+ - View surveys
+ - View collectors
+ - View responses
- View response details`, }, { diff --git a/packages/nodes-base/nodes/Asana/GenericFunctions.ts b/packages/nodes-base/nodes/Asana/GenericFunctions.ts index 83bdfe01a2..1f6a1a5252 100644 --- a/packages/nodes-base/nodes/Asana/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Asana/GenericFunctions.ts @@ -41,7 +41,7 @@ export async function asanaApiRequest(this: IHookFunctions | IExecuteFunctions | // Return a clear error throw new Error('The Asana credentials are not valid!'); } - + console.log(error); if (error.response && error.response.body && error.response.body.errors) { // Try to return the error prettier const errorMessages = error.response.body.errors.map((errorData: { message: string }) => { diff --git a/packages/nodes-base/nodes/SurveyMonkey/GenericFunctions.ts b/packages/nodes-base/nodes/SurveyMonkey/GenericFunctions.ts index 2d2cf7995d..86f999b578 100644 --- a/packages/nodes-base/nodes/SurveyMonkey/GenericFunctions.ts +++ b/packages/nodes-base/nodes/SurveyMonkey/GenericFunctions.ts @@ -1,6 +1,6 @@ import { OptionsWithUri, - } from 'request'; +} from 'request'; import { IExecuteFunctions, @@ -52,7 +52,7 @@ export async function surveyMonkeyApiRequest(this: IExecuteFunctions | IWebhookF } } -export async function surveyMonkeyRequestAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, propertyName: string, method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any +export async function surveyMonkeyRequestAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, propertyName: string, method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any const returnData: IDataObject[] = []; diff --git a/packages/nodes-base/nodes/SurveyMonkey/Interfaces.ts b/packages/nodes-base/nodes/SurveyMonkey/Interfaces.ts new file mode 100644 index 0000000000..56c4e03a29 --- /dev/null +++ b/packages/nodes-base/nodes/SurveyMonkey/Interfaces.ts @@ -0,0 +1,47 @@ +import { + IDataObject, + } from 'n8n-workflow'; + +export interface IImage { + url: string; +} + +export interface IChoice { + position: number; + visible: boolean; + text: string; + id: string; + weight: number; + description: string; + image?: IImage; +} + +export interface IRow { + position: number; + visible: boolean; + text: string; + id: string; +} + +export interface IOther { + text: string; + visible: boolean; + is_answer_choice: boolean; + id: string; +} + +export interface IQuestion { + id: string; + family?: string; + subtype?: string; + headings?: IDataObject[]; + answers: IDataObject; + rows?: IDataObject; +} + +export interface IAnswer { + choice_id: string; + row_id?: string; + text?: string; + other_id?: string; +} diff --git a/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts b/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts index 51d101697a..49c44c9ac0 100644 --- a/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts +++ b/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts @@ -19,9 +19,17 @@ import { surveyMonkeyRequestAllItems, } from './GenericFunctions'; +import { + IAnswer, + IChoice, + IQuestion, + IRow, + IOther, +} from './Interfaces'; + import { createHmac, - } from 'crypto'; +} from 'crypto'; export class SurveyMonkeyTrigger implements INodeType { description: INodeTypeDescription = { @@ -58,9 +66,33 @@ export class SurveyMonkeyTrigger implements INodeType { }, ], properties: [ + { + displayName: 'Type', + name: 'objectType', + type: 'options', + options: [ + { + name: 'Collector', + value: 'collector', + }, + { + name: 'Survey', + value: 'survey', + }, + ], + default: '', + required: true, + }, { displayName: 'Event', name: 'event', + displayOptions: { + show: { + objectType: [ + 'survey' + ], + }, + }, type: 'options', options: [ { @@ -94,8 +126,8 @@ export class SurveyMonkeyTrigger implements INodeType { description: 'A response is deleted', }, { - name: 'Response Desqualified', - value: 'response_desqualified', + name: 'Response Disqualified', + value: 'response_disqualified', description: 'A survey response is disqualified ', }, { @@ -128,17 +160,56 @@ export class SurveyMonkeyTrigger implements INodeType { required: true, }, { - displayName: 'Type', - name: 'objectType', + displayName: 'Event', + name: 'event', type: 'options', + displayOptions: { + show: { + objectType: [ + 'collector', + ], + }, + }, options: [ { - name: 'Collector', - value: 'collector', + name: 'Collector Updated', + value: 'collector_updated', + description: 'A collector is updated', }, { - name: 'Survey', - value: 'survey', + name: 'Collector Deleted', + value: 'collector_deleted', + description: 'A collector is deleted', + }, + { + name: 'Response Completed', + value: 'response_completed', + description: 'A survey response is completed', + }, + { + name: 'Response Created', + value: 'response_created', + description: 'A respondent begins a survey', + }, + { + name: 'Response Deleted', + value: 'response_deleted', + description: 'A response is deleted', + }, + { + name: 'Response Disqualified', + value: 'response_disqualified', + description: 'A survey response is disqualified ', + }, + { + name: 'Response Overquota', + value: 'response_overquota', + description: `A response is over a survey’s quota`, + }, + { + name: 'Response Updated', + value: 'response_updated', + description: 'A survey response is updated', }, ], default: '', @@ -219,6 +290,23 @@ export class SurveyMonkeyTrigger implements INodeType { default: true, description: 'By default the webhook-data only contain the IDs. If this option gets activated it
will resolve the data automatically.', }, + { + displayName: 'Only Answers', + name: 'onlyAnswers', + displayOptions: { + show: { + resolveData: [ + true, + ], + event: [ + 'response_completed', + ], + }, + }, + type: 'boolean', + default: true, + description: 'Returns only the answers of the form and not any of the other data.', + }, ], }; @@ -275,7 +363,7 @@ export class SurveyMonkeyTrigger implements INodeType { const ids: string[] = []; - if (objectType === 'survey') { + if (objectType === 'survey' && event !== 'survey_created') { const surveyIds = this.getNodeParameter('surveyIds') as string[]; ids.push.apply(ids, surveyIds); } else if (objectType === 'collector') { @@ -305,7 +393,7 @@ export class SurveyMonkeyTrigger implements INodeType { const endpoint = '/webhooks'; const ids: string[] = []; - if (objectType === 'survey') { + if (objectType === 'survey' && event !== 'survey_created') { const surveyIds = this.getNodeParameter('surveyIds') as string[]; ids.push.apply(ids, surveyIds); } else if (objectType === 'collector') { @@ -321,12 +409,9 @@ export class SurveyMonkeyTrigger implements INodeType { event_type: event, }; - if (objectType === 'collector' && event === 'collector_created') { - throw new Error('Type collector cannot be used with collector created event'); - } - if (objectType === 'survey' && event === 'survey_created') { delete body.object_type; + delete body.object_ids; } let responseData: IDataObject = {}; @@ -390,7 +475,6 @@ export class SurveyMonkeyTrigger implements INodeType { }); req.on('end', async () => { - const computedSignature = createHmac('sha1', `${credentials.clientId}&${credentials.clientSecret}`).update(data.join('')).digest('base64'); if (headerData['sm-signature'] !== computedSignature) { // Signature is not valid so ignore call @@ -400,6 +484,12 @@ export class SurveyMonkeyTrigger implements INodeType { let responseData = JSON.parse(data.join('')); let endpoint = ''; + let returnItem: INodeExecutionData[] = [ + { + json: responseData, + } + ]; + if (event === 'response_completed') { const resolveData = this.getNodeParameter('resolveData') as boolean; if (resolveData) { @@ -409,18 +499,198 @@ export class SurveyMonkeyTrigger implements INodeType { endpoint = `/collectors/${responseData.resources.collector_id}/responses/${responseData.object_id}/details`; } responseData = await surveyMonkeyApiRequest.call(this, 'GET', endpoint); + const surveyId = responseData.survey_id; + + const questions: IQuestion[] = []; + const answers = new Map(); + + const { pages } = await surveyMonkeyApiRequest.call(this, 'GET', `/surveys/${surveyId}/details`); + + for (const page of pages) { + questions.push.apply(questions, page.questions); + } + + for (const page of responseData.pages as IDataObject[]) { + for (const question of page.questions as IDataObject[]) { + answers.set(question.id as string, question.answers as IAnswer[]); + } + } + + const responseQuestions = new Map(); + + for (const question of questions) { + + /* + TODO add support for premiun companents + - File Upload + - Matrix of dropdowm menus + */ + + // if question does not have an answer ignore it + if (!answers.get(question.id)) { + continue; + } + + const heading = question.headings![0].heading as string; + + if (question.family === 'open_ended' || question.family === 'datetime') { + if (question.subtype !== 'multi') { + responseQuestions.set(heading, answers.get(question.id)![0].text as string); + } else { + + const results: IDataObject = {}; + const keys = (question.answers.rows as IRow[]).map(e => e.text) as string[]; + const values = answers.get(question.id)?.map(e => e.text) as string[]; + for (let i = 0; i < keys.length; i++) { + // if for some reason there are questions texts repeted add the index to the key + if (results[keys[i]] !== undefined) { + results[`${keys[i]}(${i})`] = values[i] || ''; + } else { + results[keys[i]] = values[i] || ''; + } + } + responseQuestions.set(heading, results); + } + } + + if (question.family === 'single_choice') { + const other = question.answers.other as IOther; + if (other && other.visible && other.is_answer_choice && answers.get(question.id)![0].other_id) { + responseQuestions.set(heading, answers.get(question.id)![0].text as string); + + } else if (other && other.visible && !other.is_answer_choice){ + const choiceId = answers.get(question.id)![0].choice_id; + + const choice = (question.answers.choices as IChoice[]) + .filter(e => e.id === choiceId)[0]; + + const comment = answers.get(question.id) + ?.find(e => e.other_id === other.id)?.text as string; + responseQuestions.set(heading, { value: choice.text, comment }); + + } else { + const choiceId = answers.get(question.id)![0].choice_id; + const choice = (question.answers.choices as IChoice[]) + .filter(e => e.id === choiceId)[0]; + responseQuestions.set(heading, choice.text); + } + } + + if (question.family === 'multiple_choice') { + const other = question.answers.other as IOther; + const choiceIds = answers.get(question.id)?.map((e) => e.choice_id); + const value = (question.answers.choices as IChoice[]) + .filter(e => choiceIds?.includes(e.id)) + .map(e => e.text) as string[]; + // if "Add an "Other" Answer Option for Comments" is active and was selected + if (other && other.is_answer_choice && other.visible) { + const text = answers.get(question.id) + ?.find(e => e.other_id === other.id)?.text as string; + value.push(text); + } + responseQuestions.set(heading, value); + } + + if (question.family === 'matrix') { + // if more than one row it's a matrix/rating-scale + const rows = question.answers.rows as IRow[]; + + if (rows.length > 1) { + + const results: IDataObject = {}; + const choiceIds = answers.get(question.id)?.map(e => e.choice_id) as string[]; + const rowIds = answers.get(question.id)?.map(e => e.row_id) as string[]; + + const rowsValues = (question.answers.rows as IRow[]) + .filter(e => rowIds!.includes(e.id as string)) + .map(e => e.text); + + const choicesValues = (question.answers.choices as IChoice[]) + .filter(e => choiceIds!.includes(e.id as string)) + .map(e => e.text); + + for (let i = 0; i < rowsValues.length; i++) { + results[rowsValues[i]] = choicesValues[i] || ''; + } + + // add the rows that were not answered + for (const row of question.answers.rows as IDataObject[]) { + if (!rowIds.includes(row.id as string)) { + results[row.text as string] = ''; + } + } + // the comment then add the comment + const other = question.answers.other as IOther; + if (other !== undefined && other.visible) { + results.comment = answers.get(question.id)?.filter((e) => e.other_id)[0].text; + } + + responseQuestions.set(heading, results); + + } else { + const choiceIds = answers.get(question.id)?.map((e) => e.choice_id); + const value = (question.answers.choices as IChoice[]) + .filter(e => choiceIds!.includes(e.id as string)) + .map(e => (e.text === '') ? e.weight : e.text)[0]; + responseQuestions.set(heading, value); + + // if "Add an Other Answer Option for Comments" is active then add comment to the answer + const other = question.answers.other as IOther; + if (other !== undefined && other.visible) { + const response: IDataObject = {}; + //const questionName = (question.answers.other as IOther).text as string; + const text = answers.get(question.id)?.filter((e) => e.other_id)[0].text; + response.value = value; + response.comment = text; + responseQuestions.set(heading, response); + } + } + } + + if (question.family === 'demographic') { + const rows: IDataObject = {}; + for (const row of answers.get(question.id) as IAnswer[]) { + rows[row.row_id as string] = row.text; + } + const addressInfo: IDataObject = {}; + for (const answer of question.answers.rows as IDataObject[]) { + addressInfo[answer.type as string] = rows[answer.id as string] || ''; + } + responseQuestions.set(heading, addressInfo); + } + + if (question.family === 'presentation') { + if (question.subtype === 'image') { + const { url } = question.headings![0].image as IDataObject; + responseQuestions.set(heading, url as string); + } + } + } + delete responseData.pages; + responseData.questions = {}; + + // Map the "Map" to JSON + const tuples = JSON.parse(JSON.stringify([...responseQuestions])); + for (const [key, value] of tuples) { + responseData.questions[key] = value; + } + + const onlyAnswers = this.getNodeParameter('onlyAnswers') as boolean; + if (onlyAnswers) { + responseData = responseData.questions; + } + + returnItem = [ + { + json: responseData, + } + ]; } } - const returnItem: INodeExecutionData = { - json: responseData, - }; - return resolve({ workflowData: [ - [ - returnItem, - ], + returnItem, ], }); }); From 0b438e27388595c9d901d67bae262754780f8380 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sat, 2 May 2020 12:30:23 +0200 Subject: [PATCH 2/2] :zap: Small fixes --- packages/nodes-base/nodes/Asana/GenericFunctions.ts | 2 +- .../nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/nodes/Asana/GenericFunctions.ts b/packages/nodes-base/nodes/Asana/GenericFunctions.ts index 1f6a1a5252..83bdfe01a2 100644 --- a/packages/nodes-base/nodes/Asana/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Asana/GenericFunctions.ts @@ -41,7 +41,7 @@ export async function asanaApiRequest(this: IHookFunctions | IExecuteFunctions | // Return a clear error throw new Error('The Asana credentials are not valid!'); } - console.log(error); + if (error.response && error.response.body && error.response.body.errors) { // Try to return the error prettier const errorMessages = error.response.body.errors.map((errorData: { message: string }) => { diff --git a/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts b/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts index 49c44c9ac0..efdc8dba5a 100644 --- a/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts +++ b/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts @@ -521,7 +521,7 @@ export class SurveyMonkeyTrigger implements INodeType { for (const question of questions) { /* - TODO add support for premiun companents + TODO: add support for premium components - File Upload - Matrix of dropdowm menus */