From 35e258dcb604bd397470642de53203fb27bea36c Mon Sep 17 00:00:00 2001 From: Val <68596159+valya@users.noreply.github.com> Date: Fri, 17 Mar 2023 11:50:26 +0000 Subject: [PATCH] feat(QuickChart Node): Add QuickChart node (#3572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: Add basic QuickChart node * :label: Fix up types * :sparkle: Add Boxplot and Violin * :sparkle: Add point styles * :sparkle: Add horizontal charts * :zap: Make possible to provide array of labels via expressions * :zap: Improvements * :zap: Improvements * 🎨 fix lint errors * ⚡️disable chart types we don't want to support in P0 * ⚡️support setting labels manually or using an array * ⚡️move Horizontal parameter into options * ⚡️ update "Put Output In Field" param description and hint * ⚡️ removed font color * ⚡️fix Device Pixel Ratio * ⚡️fix Polar Chart not working * ⚡️Show Fill param only for charts supporting it * ⚡️Show pointStyle param only for charts supporting it * ⚡️remove second "Chart Type" option * :zap: updated error message, added json data, updated description * Add codex json file * ✅ add unit test * ✅ improve unit test * :zap: removed any, added aliases --------- Co-authored-by: ricardo Co-authored-by: Marcus Co-authored-by: Michael Kret --- .../nodes/QuickChart/QuickChart.node.json | 19 + .../nodes/QuickChart/QuickChart.node.ts | 430 ++++++++++++++++++ .../nodes-base/nodes/QuickChart/constants.ts | 30 ++ .../nodes/QuickChart/quickChart.svg | 14 + .../QuickChart/test/QuickChart.node.test.ts | 110 +++++ .../QuickChart/test/QuickChart.workflow.json | 132 ++++++ packages/nodes-base/nodes/QuickChart/types.ts | 12 + packages/nodes-base/package.json | 30 ++ 8 files changed, 777 insertions(+) create mode 100644 packages/nodes-base/nodes/QuickChart/QuickChart.node.json create mode 100644 packages/nodes-base/nodes/QuickChart/QuickChart.node.ts create mode 100644 packages/nodes-base/nodes/QuickChart/constants.ts create mode 100644 packages/nodes-base/nodes/QuickChart/quickChart.svg create mode 100644 packages/nodes-base/nodes/QuickChart/test/QuickChart.node.test.ts create mode 100644 packages/nodes-base/nodes/QuickChart/test/QuickChart.workflow.json create mode 100644 packages/nodes-base/nodes/QuickChart/types.ts diff --git a/packages/nodes-base/nodes/QuickChart/QuickChart.node.json b/packages/nodes-base/nodes/QuickChart/QuickChart.node.json new file mode 100644 index 0000000000000..364fbf6f4d22f --- /dev/null +++ b/packages/nodes-base/nodes/QuickChart/QuickChart.node.json @@ -0,0 +1,19 @@ +{ + "node": "n8n-nodes-base.quickChart", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Marketing & Content"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/credentials/quickchart" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.quickchart/" + } + ] + }, + "alias": ["image", "graph", "report", "chart", "diagram", "data", "visualize"] +} diff --git a/packages/nodes-base/nodes/QuickChart/QuickChart.node.ts b/packages/nodes-base/nodes/QuickChart/QuickChart.node.ts new file mode 100644 index 0000000000000..9b36934cded67 --- /dev/null +++ b/packages/nodes-base/nodes/QuickChart/QuickChart.node.ts @@ -0,0 +1,430 @@ +import type { + IDataObject, + IExecuteFunctions, + IHttpRequestOptions, + IN8nHttpFullResponse, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; +import { jsonParse, NodeOperationError } from 'n8n-workflow'; + +import { + CHART_TYPE_OPTIONS, + Fill_CHARTS, + HORIZONTAL_CHARTS, + ITEM_STYLE_CHARTS, + POINT_STYLE_CHARTS, +} from './constants'; +import type { IDataset } from './types'; + +import _ from 'lodash'; +export class QuickChart implements INodeType { + description: INodeTypeDescription = { + displayName: 'QuickChart', + name: 'quickChart', + icon: 'file:quickChart.svg', + group: ['output'], + description: 'Create a chart via QuickChart', + version: 1, + defaults: { + name: 'QuickChart', + }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { + displayName: 'Chart Type', + name: 'chartType', + type: 'options', + default: 'bar', + options: CHART_TYPE_OPTIONS, + description: 'The type of chart to create', + }, + { + displayName: 'Add Labels', + name: 'labelsMode', + type: 'options', + options: [ + { + name: 'Manually', + value: 'manually', + }, + { + name: 'From Array', + value: 'array', + }, + ], + default: 'manually', + }, + { + displayName: 'Labels', + name: 'labelsUi', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + sortable: true, + }, + default: {}, + required: true, + description: 'Labels to use in the chart', + placeholder: 'Add Label', + options: [ + { + name: 'labelsValues', + displayName: 'Labels', + values: [ + { + displayName: 'Label', + name: 'label', + type: 'string', + default: '', + }, + ], + }, + ], + displayOptions: { + show: { + labelsMode: ['manually'], + }, + }, + }, + { + displayName: 'Labels Array', + name: 'labelsArray', + type: 'string', + required: true, + default: '', + placeholder: 'e.g. ["Berlin", "Paris", "Rome", "New York"]', + displayOptions: { + show: { + labelsMode: ['array'], + }, + }, + description: 'The array of labels to be used in the chart', + }, + { + displayName: 'Data', + name: 'data', + type: 'json', + default: '', + description: + 'Data to use for the dataset, documentation and examples here', + placeholder: 'e.g. [60, 10, 12, 20]', + required: true, + }, + { + displayName: 'Put Output In Field', + name: 'output', + type: 'string', + default: 'data', + required: true, + description: + 'The binary data will be displayed in the Output panel on the right, under the Binary tab', + hint: 'The name of the output field to put the binary file data in', + }, + { + displayName: 'Chart Options', + name: 'chartOptions', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Background Color', + name: 'backgroundColor', + type: 'color', + typeOptions: { + showAlpha: true, + }, + default: '', + description: 'Background color of the chart', + }, + { + displayName: 'Device Pixel Ratio', + name: 'devicePixelRatio', + type: 'number', + default: 2, + typeOptions: { + minValue: 1, + maxValue: 2, + }, + description: 'Pixel ratio of the chart', + }, + { + displayName: 'Format', + name: 'format', + type: 'options', + default: 'png', + description: 'File format of the resulting chart', + options: [ + { + name: 'PNG', + value: 'png', + }, + { + name: 'PDF', + value: 'pdf', + }, + { + name: 'SVG', + value: 'svg', + }, + { + name: 'WebP', + value: 'webp', + }, + ], + }, + { + displayName: 'Height', + name: 'height', + type: 'number', + default: 300, + description: 'Height of the chart', + }, + { + displayName: 'Horizontal', + name: 'horizontal', + type: 'boolean', + default: false, + description: 'Whether the chart should use its Y axis horizontal', + displayOptions: { + show: { + '/chartType': HORIZONTAL_CHARTS, + }, + }, + }, + { + displayName: 'Width', + name: 'width', + type: 'number', + default: 500, + description: 'Width of the chart', + }, + ], + }, + { + displayName: 'Dataset Options', + name: 'datasetOptions', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Background Color', + name: 'backgroundColor', + type: 'color', + default: '', + typeOptions: { + showAlpha: true, + }, + description: + 'Color used for the background the dataset (area of a line graph, fill of a bar chart, etc.)', + }, + { + displayName: 'Border Color', + name: 'borderColor', + type: 'color', + typeOptions: { + showAlpha: true, + }, + default: '', + description: 'Color used for lines of the dataset', + }, + { + displayName: 'Fill', + name: 'fill', + type: 'boolean', + default: true, + description: 'Whether to fill area of the dataset', + displayOptions: { + show: { + '/chartType': Fill_CHARTS, + }, + }, + }, + { + displayName: 'Label', + name: 'label', + type: 'string', + default: '', + description: 'The label of the dataset', + }, + { + displayName: 'Point Style', + name: 'pointStyle', + type: 'options', + default: 'circle', + description: 'Style to use for points of the dataset', + options: [ + { + name: 'Circle', + value: 'circle', + }, + { + name: 'Cross', + value: 'cross', + }, + { + name: 'CrossRot', + value: 'crossRot', + }, + { + name: 'Dash', + value: 'dash', + }, + { + name: 'Line', + value: 'line', + }, + { + name: 'Rect', + value: 'rect', + }, + { + name: 'Rect Rot', + value: 'rectRot', + }, + { + name: 'Rect Rounded', + value: 'rectRounded', + }, + { + name: 'Star', + value: 'star', + }, + { + name: 'Triangle', + value: 'triangle', + }, + ], + displayOptions: { + show: { + '/chartType': POINT_STYLE_CHARTS, + }, + }, + }, + ], + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const datasets: IDataset[] = []; + let chartType = ''; + + const labels: string[] = []; + const labelsMode = this.getNodeParameter('labelsMode', 0) as string; + + if (labelsMode === 'manually') { + const labelsUi = this.getNodeParameter('labelsUi.labelsValues', 0, []) as IDataObject[]; + + if (labelsUi.length) { + for (const labelValue of labelsUi as [{ label: string[] | string }]) { + if (Array.isArray(labelValue.label)) { + labels?.push(...labelValue.label); + } else { + labels?.push(labelValue.label); + } + } + } + } else { + const labelsArray = this.getNodeParameter('labelsArray', 0, '') as string; + + const errorMessage = + 'Labels Array is not a valid array, use valid JSON format, or specify it by expressions'; + + if (Array.isArray(labelsArray)) { + labels.push(...labelsArray); + } else { + const labelsArrayParsed = jsonParse(labelsArray, { + errorMessage, + }); + if (!Array.isArray(labelsArrayParsed)) { + throw new NodeOperationError(this.getNode(), errorMessage); + } + labels.push(...labelsArrayParsed); + } + } + + for (let i = 0; i < items.length; i++) { + const data = this.getNodeParameter('data', i) as string; + const datasetOptions = this.getNodeParameter('datasetOptions', i) as IDataObject; + + const backgroundColor = datasetOptions.backgroundColor as string; + const borderColor = datasetOptions.borderColor as string | undefined; + const fill = datasetOptions.fill as boolean | undefined; + const label = (datasetOptions.label as string) || 'Chart'; + const pointStyle = datasetOptions.pointStyle as string | undefined; + + chartType = this.getNodeParameter('chartType', i) as string; + + if (HORIZONTAL_CHARTS.includes(chartType)) { + const horizontal = this.getNodeParameter('chartOptions.horizontal', i, false) as boolean; + if (horizontal) { + chartType = + 'horizontal' + chartType[0].toUpperCase() + chartType.substring(1, chartType.length); + } + } + + // Boxplots and Violins are an addon that uses the name 'itemStyle' + // instead of 'pointStyle'. + let pointStyleName = 'pointStyle'; + if (ITEM_STYLE_CHARTS.includes(chartType)) { + pointStyleName = 'itemStyle'; + } + + datasets.push({ + label, + data, + backgroundColor, + borderColor, + type: chartType, + fill, + [pointStyleName]: pointStyle, + }); + } + + const output = this.getNodeParameter('output', 0) as string; + const chartOptions = this.getNodeParameter('chartOptions', 0) as IDataObject; + + const chart = { + type: chartType, + data: { + labels, + datasets, + }, + }; + + const options: IHttpRequestOptions = { + method: 'GET', + url: 'https://quickchart.io/chart', + qs: { + chart: JSON.stringify(chart), + ...chartOptions, + }, + returnFullResponse: true, + encoding: 'arraybuffer', + json: false, + }; + + const response = (await this.helpers.httpRequest(options)) as IN8nHttpFullResponse; + let mimeType = response.headers['content-type'] as string | undefined; + mimeType = mimeType ? mimeType.split(';').find((value) => value.includes('/')) : undefined; + + return this.prepareOutputData([ + { + binary: { + [output]: await this.helpers.prepareBinaryData( + response.body as Buffer, + undefined, + mimeType, + ), + }, + json: { chart }, + }, + ]); + } +} diff --git a/packages/nodes-base/nodes/QuickChart/constants.ts b/packages/nodes-base/nodes/QuickChart/constants.ts new file mode 100644 index 0000000000000..20d93ddd5796d --- /dev/null +++ b/packages/nodes-base/nodes/QuickChart/constants.ts @@ -0,0 +1,30 @@ +import type { INodePropertyOptions } from 'n8n-workflow'; + +// Disable some charts that use different datasets for now +export const CHART_TYPE_OPTIONS: INodePropertyOptions[] = [ + { + name: 'Bar Chart', + value: 'bar', + }, + { + name: 'Doughnut Chart', + value: 'doughnut', + }, + { + name: 'Line Chart', + value: 'line', + }, + { + name: 'Pie Chart', + value: 'pie', + }, + { + name: 'Polar Chart', + value: 'polarArea', + }, +]; + +export const HORIZONTAL_CHARTS = ['bar', 'boxplot', 'violin']; +export const ITEM_STYLE_CHARTS = ['boxplot', 'horizontalBoxplot', 'violin', 'horizontalViolin']; +export const Fill_CHARTS = ['line']; +export const POINT_STYLE_CHARTS = ['line']; diff --git a/packages/nodes-base/nodes/QuickChart/quickChart.svg b/packages/nodes-base/nodes/QuickChart/quickChart.svg new file mode 100644 index 0000000000000..0f2ae092a660c --- /dev/null +++ b/packages/nodes-base/nodes/QuickChart/quickChart.svg @@ -0,0 +1,14 @@ + + + +Created with Fabric.js 1.7.22 + + + + + + + + + + \ No newline at end of file diff --git a/packages/nodes-base/nodes/QuickChart/test/QuickChart.node.test.ts b/packages/nodes-base/nodes/QuickChart/test/QuickChart.node.test.ts new file mode 100644 index 0000000000000..77f125fd51bc8 --- /dev/null +++ b/packages/nodes-base/nodes/QuickChart/test/QuickChart.node.test.ts @@ -0,0 +1,110 @@ +/* eslint-disable @typescript-eslint/no-loop-func */ +import * as Helpers from '../../../test/nodes/Helpers'; +import type { WorkflowTestData } from '../../../test/nodes/types'; +import { executeWorkflow } from '../../../test/nodes/ExecuteWorkflow'; +import nock from 'nock'; + +describe('Test QuickChart Node', () => { + beforeEach(async () => { + await Helpers.initBinaryDataManager(); + nock.disableNetConnect(); + nock('https://quickchart.io') + .persist() + .get(/chart.*/) + .reply(200, { success: true }); + }); + + afterEach(() => { + nock.restore(); + }); + + const workflow = Helpers.readJsonFileSync('nodes/QuickChart/test/QuickChart.workflow.json'); + + const tests: WorkflowTestData[] = [ + { + description: 'nodes/QuickChart/test/QuickChart.workflow.json', + input: { + workflowData: workflow, + }, + output: { + nodeData: { + BarChart: [ + [ + { + json: { + chart: { + type: 'horizontalBar', + data: { + labels: ['Q1', 'Q2', 'Q3', 'Q4'], + datasets: [ + { + label: 'Free Users', + data: [50, 60, 70, 180], + backgroundColor: '#121d6d77', + borderColor: '#e81010', + type: 'horizontalBar', + }, + { + label: 'Paid Users', + data: [30, 10, 14, 25], + backgroundColor: '#0c0d0d96', + borderColor: '#e81010', + type: 'horizontalBar', + }, + ], + }, + }, + }, + }, + ], + ], + Doughnut: [ + [ + { + json: { + chart: { + type: 'doughnut', + data: { + labels: ['Q1', 'Q2', 'Q3', 'Q4'], + datasets: [ + { + label: 'Free Users', + data: [50, 60, 70, 180], + backgroundColor: '#121d6d77', + borderColor: '#e81010', + type: 'doughnut', + }, + { + label: 'Paid Users', + data: [30, 10, 14, 25], + backgroundColor: '#0c0d0d96', + borderColor: '#e81010', + type: 'doughnut', + }, + ], + }, + }, + }, + }, + ], + ], + }, + }, + }, + ]; + + const nodeTypes = Helpers.setup(tests); + + for (const testData of tests) { + test(testData.description, async () => { + const { result } = await executeWorkflow(testData, nodeTypes); + + const resultNodeData = Helpers.getResultNodeData(result, testData); + resultNodeData.forEach(({ nodeName, resultData }) => { + delete resultData[0]![0].binary; + expect(resultData).toEqual(testData.output.nodeData[nodeName]); + }); + expect(result.finished).toEqual(true); + }); + } +}); diff --git a/packages/nodes-base/nodes/QuickChart/test/QuickChart.workflow.json b/packages/nodes-base/nodes/QuickChart/test/QuickChart.workflow.json new file mode 100644 index 0000000000000..73fbcc3ead1f0 --- /dev/null +++ b/packages/nodes-base/nodes/QuickChart/test/QuickChart.workflow.json @@ -0,0 +1,132 @@ +{ + "meta": { + "instanceId": "104a4d08d8897b8bdeb38aaca515021075e0bd8544c983c2bb8c86e6a8e6081c" + }, + "nodes": [ + { + "parameters": {}, + "id": "5ebe2f65-45db-4b22-bd2b-43993c20806f", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [740, 320] + }, + { + "parameters": { + "jsCode": "return [\n {\n label: 'Free Users',\n labels: [\"Berlin\", \"Paris\", \"Rome\", \"New York\"],\n data: [50, 60, 70, 180],\n backgroundColor: '#121d6d77',\n chartType: 'line'\n },\n {\n label: 'Paid Users',\n labels: [\"Berlin\", \"Paris\", \"Rome\", \"New York\"],\n data: [30, 10, 14, 25],\n backgroundColor: '#0c0d0d96',\n chartType: 'bar'\n },\n]" + }, + "id": "2e81f78c-41a5-48de-80c4-74abf163cd57", + "name": "Code", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [980, 320] + }, + { + "parameters": { + "labelsUi": { + "labelsValues": [ + { + "label": "Q1" + }, + { + "label": "Q2" + }, + { + "label": "Q3" + }, + { + "label": "Q4" + } + ] + }, + "data": "={{ $json.data }}", + "chartOptions": { + "backgroundColor": "#f93636ff", + "devicePixelRatio": 2, + "format": "png", + "height": 300, + "horizontal": true, + "width": 500 + }, + "datasetOptions": { + "backgroundColor": "={{ $json[\"backgroundColor\"] }}", + "borderColor": "#e81010", + "label": "={{ $json[\"label\"] }}" + } + }, + "name": "BarChart", + "type": "n8n-nodes-base.quickChart", + "typeVersion": 1, + "position": [1220, 200], + "id": "9f6c9d1c-2732-473f-a357-5766265cd0db" + }, + { + "parameters": { + "chartType": "doughnut", + "labelsUi": { + "labelsValues": [ + { + "label": "Q1" + }, + { + "label": "Q2" + }, + { + "label": "Q3" + }, + { + "label": "Q4" + } + ] + }, + "data": "={{ $json.data }}", + "chartOptions": { + "backgroundColor": "#f93636ff", + "devicePixelRatio": 2, + "format": "png", + "height": 300, + "width": 500 + }, + "datasetOptions": { + "backgroundColor": "={{ $json[\"backgroundColor\"] }}", + "borderColor": "#e81010", + "label": "={{ $json[\"label\"] }}" + } + }, + "name": "Doughnut", + "type": "n8n-nodes-base.quickChart", + "typeVersion": 1, + "position": [1220, 400], + "id": "6c8e1463-c384-4f5c-9de3-d7e052b02b0a" + } + ], + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Code", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code": { + "main": [ + [ + { + "node": "BarChart", + "type": "main", + "index": 0 + }, + { + "node": "Doughnut", + "type": "main", + "index": 0 + } + ] + ] + } + } +} diff --git a/packages/nodes-base/nodes/QuickChart/types.ts b/packages/nodes-base/nodes/QuickChart/types.ts new file mode 100644 index 0000000000000..11c1b07e15053 --- /dev/null +++ b/packages/nodes-base/nodes/QuickChart/types.ts @@ -0,0 +1,12 @@ +import type { IDataObject } from 'n8n-workflow'; + +export interface IDataset { + label?: string; + data: string | IDataObject; + backgroundColor?: string; + borderColor?: string; + color?: string; + type?: string; + fill?: boolean; + pointStyle?: string; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 5119ac5f9d25d..238b46ba95014 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -152,6 +152,36 @@ "dist/nodes/N8nTrigger/N8nTrigger.node.js", "dist/nodes/NoOp/NoOp.node.js", "dist/nodes/OpenAi/OpenAi.node.js", + "dist/nodes/OpenThesaurus/OpenThesaurus.node.js", + "dist/nodes/OpenWeatherMap/OpenWeatherMap.node.js", + "dist/nodes/Orbit/Orbit.node.js", + "dist/nodes/Oura/Oura.node.js", + "dist/nodes/Paddle/Paddle.node.js", + "dist/nodes/PagerDuty/PagerDuty.node.js", + "dist/nodes/PayPal/PayPal.node.js", + "dist/nodes/PayPal/PayPalTrigger.node.js", + "dist/nodes/Peekalink/Peekalink.node.js", + "dist/nodes/Phantombuster/Phantombuster.node.js", + "dist/nodes/PhilipsHue/PhilipsHue.node.js", + "dist/nodes/Pipedrive/Pipedrive.node.js", + "dist/nodes/Pipedrive/PipedriveTrigger.node.js", + "dist/nodes/Plivo/Plivo.node.js", + "dist/nodes/PostBin/PostBin.node.js", + "dist/nodes/Postgres/Postgres.node.js", + "dist/nodes/PostHog/PostHog.node.js", + "dist/nodes/Postmark/PostmarkTrigger.node.js", + "dist/nodes/ProfitWell/ProfitWell.node.js", + "dist/nodes/Pushbullet/Pushbullet.node.js", + "dist/nodes/Pushcut/Pushcut.node.js", + "dist/nodes/Pushcut/PushcutTrigger.node.js", + "dist/nodes/Pushover/Pushover.node.js", + "dist/nodes/QuestDb/QuestDb.node.js", + "dist/nodes/QuickBase/QuickBase.node.js", + "dist/nodes/QuickBooks/QuickBooks.node.js", + "dist/nodes/QuickChart/QuickChart.node.js", + "dist/nodes/RabbitMQ/RabbitMQ.node.js", + "dist/nodes/RabbitMQ/RabbitMQTrigger.node.js", + "dist/nodes/Raindrop/Raindrop.node.js", "dist/nodes/ReadBinaryFile/ReadBinaryFile.node.js", "dist/nodes/ReadBinaryFiles/ReadBinaryFiles.node.js", "dist/nodes/ReadPdf/ReadPDF.node.js",