From f7ec5209cfa328e98ccc00ee744ccfee53060e77 Mon Sep 17 00:00:00 2001 From: Hetu Nandu Date: Fri, 28 Aug 2020 17:37:37 +0530 Subject: [PATCH] Add function to download part of the Data tree as a file (#458) --- app/client/package.json | 2 + .../actioncreator/ActionCreator.tsx | 129 +++++++++++++++++- .../src/entities/DataTree/dataTreeFactory.ts | 8 ++ app/client/src/sagas/ActionExecutionSagas.ts | 37 ++++- .../utils/autocomplete/EntityDefinitions.ts | 4 + app/client/yarn.lock | 10 ++ 6 files changed, 188 insertions(+), 2 deletions(-) diff --git a/app/client/package.json b/app/client/package.json index d444b5929e..856a8d8823 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -49,6 +49,7 @@ "codemirror": "^5.55.0", "copy-to-clipboard": "^3.3.1", "craco-alias": "^2.1.1", + "downloadjs": "^1.4.7", "eslint": "^6.4.0", "fast-deep-equal": "^3.1.1", "flow-bin": "^0.91.0", @@ -158,6 +159,7 @@ "@storybook/preset-create-react-app": "^3.1.4", "@storybook/react": "^5.3.19", "@types/codemirror": "^0.0.96", + "@types/downloadjs": "^1.4.2", "@types/jest": "^24.0.22", "@types/react-beautiful-dnd": "^11.0.4", "@types/react-select": "^3.0.5", diff --git a/app/client/src/components/editorComponents/actioncreator/ActionCreator.tsx b/app/client/src/components/editorComponents/actioncreator/ActionCreator.tsx index 313eed5e65..49f96cfce8 100644 --- a/app/client/src/components/editorComponents/actioncreator/ActionCreator.tsx +++ b/app/client/src/components/editorComponents/actioncreator/ActionCreator.tsx @@ -32,6 +32,17 @@ const ALERT_STYLE_OPTIONS = [ { label: "Error", value: "'error'", id: "error" }, { label: "Warning", value: "'warning'", id: "warning" }, ]; + +const FILE_TYPE_OPTIONS = [ + { label: "Plain text", value: "'text/plain'", id: "text/plain" }, + { label: "HTML", value: "'text/html'", id: "text/html" }, + { label: "CSV", value: "'text/csv'", id: "text/csv" }, + { label: "JSON", value: "'application/json'", id: "application/json" }, + { label: "JPEG", value: "'image/jpeg'", id: "image/jpeg" }, + { label: "PNG", value: "'image/png'", id: "image/png" }, + { label: "SVG", value: "'image/svg+xml'", id: "image/svg+xml" }, +]; + const ACTION_TRIGGER_REGEX = /^{{([\s\S]*?)\(([\s\S]*?)\)}}$/g; //Old Regex:: /\(\) => ([\s\S]*?)(\([\s\S]*?\))/g; const ACTION_ANONYMOUS_FUNC_REGEX = /\(\) => (({[\s\S]*?})|([\s\S]*?)(\([\s\S]*?\)))/g; @@ -162,6 +173,73 @@ const storeValueTextGetter = (value: string) => { return ""; }; +const downloadDataSetter = (changeValue: any, currentValue: string): string => { + const matches = [...currentValue.matchAll(ACTION_TRIGGER_REGEX)]; + const args = matches[0][2].split(","); + args[0] = `'${changeValue}'`; + const result = currentValue.replace( + ACTION_TRIGGER_REGEX, + `{{$1(${args.join(",")})}}`, + ); + return result; +}; + +const downloadDataGetter = (value: string) => { + const matches = [...value.matchAll(ACTION_TRIGGER_REGEX)]; + if (matches.length) { + const funcArgs = matches[0][2]; + const arg = funcArgs.split(",")[0]; + return arg.substring(1, arg.length - 1); + } + return ""; +}; + +const downloadFileNameSetter = ( + changeValue: any, + currentValue: string, +): string => { + const matches = [...currentValue.matchAll(ACTION_TRIGGER_REGEX)]; + const args = matches[0][2].split(","); + args[1] = `'${changeValue}'`; + return currentValue.replace( + ACTION_TRIGGER_REGEX, + `{{$1(${args.join(",")})}}`, + ); +}; + +const downloadFileNameGetter = (value: string) => { + const matches = [...value.matchAll(ACTION_TRIGGER_REGEX)]; + if (matches.length) { + const funcArgs = matches[0][2]; + const arg = funcArgs.split(",")[1]; + return arg ? arg.substring(1, arg.length - 1) : ""; + } + return ""; +}; + +const downloadFileTypeSetter = ( + changeValue: any, + currentValue: string, +): string => { + const matches = [...currentValue.matchAll(ACTION_TRIGGER_REGEX)]; + const args = matches[0][2].split(","); + args[2] = changeValue as string; + return currentValue.replace( + ACTION_TRIGGER_REGEX, + `{{$1(${args.join(",")})}}`, + ); +}; + +const downloadFileTypeGetter = (value: string) => { + const matches = [...value.matchAll(ACTION_TRIGGER_REGEX)]; + if (matches.length) { + const funcArgs = matches[0][2]; + const arg = funcArgs.split(",")[2]; + return arg ? arg.trim() : ""; + } + return ""; +}; + type ActionCreatorProps = { value: string; isValid: boolean; @@ -178,6 +256,7 @@ const ActionType = { navigateTo: "navigateTo", showAlert: "showAlert", storeValue: "storeValue", + download: "download", }; type ActionType = typeof ActionType[keyof typeof ActionType]; @@ -282,6 +361,9 @@ const FieldType = { ALERT_TYPE_SELECTOR_FIELD: "ALERT_TYPE_SELECTOR_FIELD", KEY_TEXT_FIELD: "KEY_TEXT_FIELD", VALUE_TEXT_FIELD: "VALUE_TEXT_FIELD", + DOWNLOAD_DATA_FIELD: "DOWNLOAD_DATA_FIELD", + DOWNLOAD_FILE_NAME_FIELD: "DOWNLOAD_FILE_NAME_FIELD", + DOWNLOAD_FILE_TYPE_FIELD: "DOWNLOAD_FILE_TYPE_FIELD", }; type FieldType = typeof FieldType[keyof typeof FieldType]; @@ -404,6 +486,22 @@ const fieldConfigs: FieldConfigs = { setter: storeValueTextSetter, view: ViewTypes.TEXT_VIEW, }, + [FieldType.DOWNLOAD_DATA_FIELD]: { + getter: downloadDataGetter, + setter: downloadDataSetter, + view: ViewTypes.TEXT_VIEW, + }, + [FieldType.DOWNLOAD_FILE_NAME_FIELD]: { + getter: downloadFileNameGetter, + setter: downloadFileNameSetter, + view: ViewTypes.TEXT_VIEW, + }, + [FieldType.DOWNLOAD_FILE_TYPE_FIELD]: { + getter: downloadFileTypeGetter, + setter: (option: any, currentValue: string) => + downloadFileTypeSetter(option.value, currentValue), + view: ViewTypes.SELECTOR_VIEW, + }, }; const baseOptions: any = [ @@ -440,6 +538,10 @@ const baseOptions: any = [ label: "Store Value", value: ActionType.storeValue, }, + { + label: "Download", + value: ActionType.download, + }, ]; function getOptionsWithChildren( options: TreeDropdownOption[], @@ -567,6 +669,19 @@ function getFieldFromValue( }, ); } + if (value.indexOf("download") !== -1) { + fields.push( + { + field: FieldType.DOWNLOAD_DATA_FIELD, + }, + { + field: FieldType.DOWNLOAD_FILE_NAME_FIELD, + }, + { + field: FieldType.DOWNLOAD_FILE_TYPE_FIELD, + }, + ); + } return fields; } @@ -606,6 +721,7 @@ function renderField(props: { case FieldType.CLOSE_MODAL_FIELD: case FieldType.PAGE_SELECTOR_FIELD: case FieldType.ALERT_TYPE_SELECTOR_FIELD: + case FieldType.DOWNLOAD_FILE_TYPE_FIELD: let label = ""; let defaultText = "Select Action"; let options = props.apiOptionTree; @@ -655,10 +771,15 @@ function renderField(props: { defaultText = "Select Page"; } if (fieldType === FieldType.ALERT_TYPE_SELECTOR_FIELD) { - label = "type"; + label = "Type"; options = ALERT_STYLE_OPTIONS; defaultText = "Select type"; } + if (fieldType === FieldType.DOWNLOAD_FILE_TYPE_FIELD) { + label = "Type"; + options = FILE_TYPE_OPTIONS; + defaultText = "Select file type (optional)"; + } viewElement = (view as (props: SelectorViewProps) => JSX.Element)({ options: options, label: label, @@ -695,6 +816,8 @@ function renderField(props: { case FieldType.URL_FIELD: case FieldType.KEY_TEXT_FIELD: case FieldType.VALUE_TEXT_FIELD: + case FieldType.DOWNLOAD_DATA_FIELD: + case FieldType.DOWNLOAD_FILE_NAME_FIELD: let fieldLabel = ""; if (fieldType === FieldType.ALERT_TEXT_FIELD) { fieldLabel = "Message"; @@ -704,6 +827,10 @@ function renderField(props: { fieldLabel = "Key"; } else if (fieldType === FieldType.VALUE_TEXT_FIELD) { fieldLabel = "Value"; + } else if (fieldType === FieldType.DOWNLOAD_DATA_FIELD) { + fieldLabel = "Data to download"; + } else if (fieldType === FieldType.DOWNLOAD_FILE_NAME_FIELD) { + fieldLabel = "File name with extension"; } viewElement = (view as (props: TextViewProps) => JSX.Element)({ label: fieldLabel, diff --git a/app/client/src/entities/DataTree/dataTreeFactory.ts b/app/client/src/entities/DataTree/dataTreeFactory.ts index 552dc2663b..4e8afc6774 100644 --- a/app/client/src/entities/DataTree/dataTreeFactory.ts +++ b/app/client/src/entities/DataTree/dataTreeFactory.ts @@ -197,6 +197,14 @@ export class DataTreeFactory { }; }; actionPaths.push("storeValue"); + + dataTree.download = function(data: string, name: string, type: string) { + return { + type: "DOWNLOAD", + payload: { data, name, type }, + }; + }; + actionPaths.push("download"); } dataTree.pageList = pageList; diff --git a/app/client/src/sagas/ActionExecutionSagas.ts b/app/client/src/sagas/ActionExecutionSagas.ts index 75436caf73..d95355b3dc 100644 --- a/app/client/src/sagas/ActionExecutionSagas.ts +++ b/app/client/src/sagas/ActionExecutionSagas.ts @@ -15,11 +15,11 @@ import { all, call, put, + race, select, take, takeEvery, takeLatest, - race, } from "redux-saga/effects"; import { evaluateDataTreeWithFunctions, @@ -75,6 +75,8 @@ import { PLUGIN_TYPE_API } from "constants/ApiEditorConstants"; import { DEFAULT_EXECUTE_ACTION_TIMEOUT_MS } from "constants/ApiConstants"; import { updateAppStore } from "actions/pageActions"; import { getAppStoreName } from "constants/AppConstants"; +import downloadjs from "downloadjs"; +import { getType, Types } from "utils/TypeHelpers"; function* navigateActionSaga( action: { pageNameOrUrl: string; params: Record }, @@ -134,6 +136,36 @@ function* storeValueLocally( } } +function* downloadSaga( + action: { data: any; name: string; type: string }, + event: ExecuteActionPayloadEvent, +) { + try { + const { data, name, type } = action; + if (!name) { + AppToaster.show({ + message: "Download failed. File name was not provided", + type: "error", + }); + return; + } + const dataType = getType(data); + if (dataType === Types.ARRAY || dataType === Types.OBJECT) { + const jsonString = JSON.stringify(data, null, 2); + downloadjs(jsonString, name, type); + } else { + downloadjs(data, name, type); + } + if (event.callback) event.callback({ success: true }); + } catch (err) { + AppToaster.show({ + message: `Download failed. ${err}`, + type: "error", + }); + if (event.callback) event.callback({ success: false }); + } +} + export const getActionTimeout = ( state: AppState, actionId: string, @@ -383,6 +415,9 @@ function* executeActionTriggers( case "STORE_VALUE": yield call(storeValueLocally, trigger.payload, event); break; + case "DOWNLOAD": + yield call(downloadSaga, trigger.payload, event); + break; default: yield put( executeActionError({ diff --git a/app/client/src/utils/autocomplete/EntityDefinitions.ts b/app/client/src/utils/autocomplete/EntityDefinitions.ts index ac999811d8..0870d8279e 100644 --- a/app/client/src/utils/autocomplete/EntityDefinitions.ts +++ b/app/client/src/utils/autocomplete/EntityDefinitions.ts @@ -237,4 +237,8 @@ export const GLOBAL_FUNCTIONS = { "!doc": "Store key value data locally", "!type": "fn(key: string, value: any) -> void", }, + download: { + "!doc": "Download anything as a file", + "!type": "fn(data: any, fileName: string, fileType?: string) -> void", + }, }; diff --git a/app/client/yarn.lock b/app/client/yarn.lock index b87b567af1..f53b5a0f12 100644 --- a/app/client/yarn.lock +++ b/app/client/yarn.lock @@ -3590,6 +3590,11 @@ version "2.0.1" resolved "https://registry.yarnpkg.com/@types/dom4/-/dom4-2.0.1.tgz#506d5781b9bcab81bd9a878b198aec7dee2a6033" +"@types/downloadjs@^1.4.2": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@types/downloadjs/-/downloadjs-1.4.2.tgz#6734e60840cd8df3f0f8937c80e67efe7de6f886" + integrity sha512-9UWO+nrRhwyKcFe45SFc98sWI8RGwpVwtZiZzi0W/dWdrp1SiUWFNANLlAXZb5QCMFDbeRiAQEhRn5btLd4a4w== + "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" @@ -7332,6 +7337,11 @@ dotenv@^6.2.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-6.2.0.tgz#941c0410535d942c8becf28d3f357dbd9d476064" integrity sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w== +downloadjs@^1.4.7: + version "1.4.7" + resolved "https://registry.yarnpkg.com/downloadjs/-/downloadjs-1.4.7.tgz#f69f96f940e0d0553dac291139865a3cd0101e3c" + integrity sha1-9p+W+UDg0FU9rCkROYZaPNAQHjw= + duplexer@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1"