From 39b0a4e5a6fc74b30d044452235c9a944aece714 Mon Sep 17 00:00:00 2001 From: Hetu Nandu Date: Thu, 23 Dec 2021 19:47:20 +0530 Subject: [PATCH] feat: Native promises support in Appsmith (#8988) Co-authored-by: Apeksha Bhosale <7846888+ApekshaBhosale@users.noreply.github.com> --- app/client/craco.build.config.js | 2 +- .../editorComponents/Debugger/helpers.tsx | 2 - .../editorComponents/JSResponseView.tsx | 9 +- app/client/src/components/utils/FlagBadge.tsx | 17 + .../ActionConstants.tsx | 1 + app/client/src/constants/defs/ecmascript.json | 569 +++++++++++++++++ app/client/src/constants/messages.ts | 8 +- .../src/entities/DataTree/actionTriggers.ts | 47 +- .../src/entities/DataTree/dataTreeFactory.ts | 3 +- .../ActionExecution/ActionExecutionSagas.ts | 44 +- .../sagas/ActionExecution/CopyActionSaga.ts | 17 +- .../ActionExecution/DownloadActionSaga.ts | 18 +- .../ActionExecution/GetCurrentLocationSaga.ts | 23 +- .../src/sagas/ActionExecution/ModalSagas.ts | 26 +- .../sagas/ActionExecution/PluginActionSaga.ts | 60 +- .../ActionExecution/PromiseActionSaga.ts | 88 --- .../ActionExecution/ResetWidgetActionSaga.ts | 31 +- .../sagas/ActionExecution/SetIntervalSaga.ts | 2 - .../ActionExecution/ShowAlertActionSaga.ts | 22 +- .../src/sagas/ActionExecution/errorUtils.ts | 96 +-- app/client/src/sagas/EvaluationsSaga.ts | 156 ++++- app/client/src/sagas/JSPaneSagas.ts | 21 +- app/client/src/sagas/PostEvaluationSagas.ts | 8 - app/client/src/utils/DynamicBindingUtils.ts | 5 +- app/client/src/utils/JSPaneUtils.tsx | 2 + app/client/src/utils/WorkerUtil.test.ts | 123 +++- app/client/src/utils/WorkerUtil.ts | 122 +++- .../utils/autocomplete/EntityDefinitions.ts | 24 +- .../src/utils/autocomplete/TernServer.ts | 6 +- app/client/src/workers/Actions.test.ts | 598 ++++++++++-------- app/client/src/workers/Actions.ts | 502 ++++++--------- app/client/src/workers/DataTreeEvaluator.ts | 88 +-- .../src/workers/PromisifyAction.test.ts | 168 +++++ app/client/src/workers/PromisifyAction.ts | 100 +++ app/client/src/workers/evaluate.test.ts | 136 ++-- app/client/src/workers/evaluate.ts | 243 +++++-- app/client/src/workers/evaluation.worker.ts | 144 ++--- app/client/src/workers/evaluationUtils.ts | 3 +- 38 files changed, 2413 insertions(+), 1121 deletions(-) create mode 100644 app/client/src/components/utils/FlagBadge.tsx delete mode 100644 app/client/src/sagas/ActionExecution/PromiseActionSaga.ts create mode 100644 app/client/src/workers/PromisifyAction.test.ts create mode 100644 app/client/src/workers/PromisifyAction.ts diff --git a/app/client/craco.build.config.js b/app/client/craco.build.config.js index 9dbecabce0..41570c3bac 100644 --- a/app/client/craco.build.config.js +++ b/app/client/craco.build.config.js @@ -13,7 +13,7 @@ plugins.push( swSrc: "./src/serviceWorker.js", mode: "development", swDest: "./pageService.js", - maximumFileSizeToCacheInBytes: 6 * 1024 * 1024, + maximumFileSizeToCacheInBytes: 7 * 1024 * 1024, }), ); diff --git a/app/client/src/components/editorComponents/Debugger/helpers.tsx b/app/client/src/components/editorComponents/Debugger/helpers.tsx index c4d8ad1ef7..fd34428f41 100644 --- a/app/client/src/components/editorComponents/Debugger/helpers.tsx +++ b/app/client/src/components/editorComponents/Debugger/helpers.tsx @@ -64,8 +64,6 @@ export function getDependenciesFromInverseDependencies( deps: DependencyMap, entityName: string | null, ) { - // eslint-disable-next-line no-console - console.log("DEPENDENCY", deps); if (!entityName) return null; const directDependencies = new Set(); diff --git a/app/client/src/components/editorComponents/JSResponseView.tsx b/app/client/src/components/editorComponents/JSResponseView.tsx index cf7e6b09a7..bca585c5df 100644 --- a/app/client/src/components/editorComponents/JSResponseView.tsx +++ b/app/client/src/components/editorComponents/JSResponseView.tsx @@ -40,6 +40,7 @@ import { setCurrentTab } from "actions/debuggerActions"; import { DEBUGGER_TAB_KEYS } from "./Debugger/helpers"; import EntityBottomTabs from "./EntityBottomTabs"; import Icon from "components/ads/Icon"; +import FlagBadge from "components/utils/FlagBadge"; const ResponseContainer = styled.div` ${ResizerCSS} @@ -91,9 +92,10 @@ const ResponseTabAction = styled.li` .function-name { margin-left: 5px; display: inline-block; + flex: 1; } .run-button { - margin-left: auto; + margin-left: 10px; margin-right: 15px; } &.active { @@ -235,6 +237,11 @@ function JSResponseView(props: Props) { > {" "}
{action.name}
+ {action.actionConfiguration.isAsync ? ( + + ) : ( + "" + )} ); diff --git a/app/client/src/components/utils/FlagBadge.tsx b/app/client/src/components/utils/FlagBadge.tsx new file mode 100644 index 0000000000..171113d859 --- /dev/null +++ b/app/client/src/components/utils/FlagBadge.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import styled from "styled-components"; + +const Flag = styled.span` + padding: 2px 5px; + border: 1px solid #716e6e; + color: #716e6e; + text-transform: uppercase; + font-size: 10px; + font-weight: 600; +`; + +function FlagBadge(props: { name: string }) { + return {props.name}; +} + +export default FlagBadge; diff --git a/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx b/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx index b76f6b6dec..6dbd6732ff 100644 --- a/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx +++ b/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx @@ -91,6 +91,7 @@ export enum EventType { ON_RECORDING_START = "ON_RECORDING_START", ON_RECORDING_COMPLETE = "ON_RECORDING_COMPLETE", ON_SWITCH_GROUP_SELECTION_CHANGE = "ON_SWITCH_GROUP_SELECTION_CHANGE", + ON_JS_FUNCTION_EXECUTE = "ON_JS_FUNCTION_EXECUTE", } export interface PageAction { diff --git a/app/client/src/constants/defs/ecmascript.json b/app/client/src/constants/defs/ecmascript.json index 4885178a32..07bac36706 100644 --- a/app/client/src/constants/defs/ecmascript.json +++ b/app/client/src/constants/defs/ecmascript.json @@ -9,6 +9,242 @@ "writable": "bool", "get": "fn() -> ?", "set": "fn(value: ?)" + }, + "Promise.prototype": { + "catch": { + "!doc": "The catch() method returns a Promise and deals with rejected cases only. It behaves the same as calling Promise.prototype.then(undefined, onRejected).", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch", + "!type": "fn(onRejected: fn(reason: ?)) -> !this" + }, + "then": { + "!doc": "The then() method returns a Promise. It takes two arguments, both are callback functions for the success and failure cases of the Promise.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then", + "!type": "fn(onFulfilled: fn(value: ?), onRejected: fn(reason: ?)) -> !custom:Promise_then", + "!effects": ["call !0 !this.:t"] + } + }, + "Promise_reject": { + "!type": "fn(reason: ?) -> !this", + "!doc": "The Promise.reject(reason) method returns a Promise object that is rejected with the given reason.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/reject" + }, + "iter_prototype": { + ":Symbol.iterator": "fn() -> !this" + }, + "iter": { + "!proto": "iter_prototype", + "next": { + "!type": "fn() -> +iter_result[value=!this.:t]", + "!doc": "Return the next item in the sequence.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Iterators_and_Generators" + }, + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Iterators_and_Generators" + }, + "iter_result": { + "done": "bool", + "value": "?" + }, + "generator_prototype": { + "!proto": "iter_prototype", + "next": "fn(value?: ?) -> iter_result", + "return": "fn(value?: ?) -> iter_result", + "throw": "fn(exception: +Error)" + }, + "Proxy_handler": { + "!doc": "The proxy's handler object is a placeholder object which contains traps for proxies.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler", + "getPrototypeOf": "fn(target: ?)", + "setPrototypeOf": "fn(target: ?, prototype: ?)", + "isExtensible": "fn(target: ?)", + "preventExtensions": "fn(target: ?)", + "getOwnPropertyDescriptor": "fn(target: ?, property: string) -> propertyDescriptor", + "defineProperty": "fn(target: ?, property: string, descriptor: propertyDescriptor)", + "has": "fn(target: ?, property: string)", + "get": "fn(target: ?, property: string)", + "set": "fn(target: ?, property: string, value: ?)", + "deleteProperty": "fn(target: ?, property: string)", + "enumerate": "fn(target: ?)", + "ownKeys": "fn(target: ?)", + "apply": "fn(target: ?, self: ?, arguments: [?])", + "construct": "fn(target: ?, arguments: [?])" + }, + "Proxy_revocable": { + "proxy": "+Proxy", + "revoke": "fn()" + }, + "TypedArray": { + "!type": "fn(size: number)", + "!doc": "A TypedArray object describes an array-like view of an underlying binary data buffer. There is no global property named TypedArray, nor is there a directly visible TypedArray constructor. Instead, there are a number of different global properties, whose values are typed array constructors for specific element types, listed below. On the following pages you will find common properties and methods that can be used with any typed array containing elements of any type.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray", + "from": { + "!type": "fn(arrayLike: ?, mapFn?: fn(elt: ?, i: number) -> number, thisArg?: ?) -> +TypedArray", + "!effects": ["call !1 this=!2 !0. number"], + "!doc": "Creates a new typed array from an array-like or iterable object.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/from" + }, + "of": { + "!type": "fn(elements: number) -> +TypedArray", + "!doc": "Creates a new typed array from a variable number of arguments.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/of" + }, + "BYTES_PER_ELEMENT": { + "!type": "number", + "!doc": "The TypedArray.BYTES_PER_ELEMENT property represents the size in bytes of each element in an typed array.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/BYTES_PER_ELEMENT" + }, + "name": { + "!type": "string", + "!doc": "The TypedArray.name property represents a string value of the typed array constructor name.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/name" + }, + "prototype": { + "": "number", + "buffer": { + "!type": "+ArrayBuffer", + "!doc": "The buffer accessor property represents the ArrayBuffer referenced by a TypedArray at construction time.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/buffer" + }, + "byteLength": { + "!type": "number", + "!doc": "The byteLength accessor property represents the length (in bytes) of a typed array from the start of its ArrayBuffer.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/byteLength" + }, + "byteOffset": { + "!type": "number", + "!doc": "The byteOffset accessor property represents the offset (in bytes) of a typed array from the start of its ArrayBuffer.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/byteOffset" + }, + "copyWithin": { + "!type": "fn(target: number, start: number, end?: number) -> ?", + "!doc": "The copyWithin() method copies the sequence of array elements within the array to the position starting at target. The copy is taken from the index positions of the second and third arguments start and end. The end argument is optional and defaults to the length of the array. This method has the same algorithm as Array.prototype.copyWithin. TypedArray is one of the typed array types here.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/copyWithin" + }, + "entries": { + "!type": "fn() -> +iter[:t=number]", + "!doc": "The entries() method returns a new Array Iterator object that contains the key/value pairs for each index in the array.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/entries" + }, + "every": { + "!type": "fn(callback: fn(element: number, index: number, array: TypedArray) -> bool, thisArg?: ?) -> bool", + "!effects": ["call !0 this=!1 number number !this"], + "!doc": "The every() method tests whether all elements in the typed array pass the test implemented by the provided function. This method has the same algorithm as Array.prototype.every(). TypedArray is one of the typed array types here.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/every" + }, + "fill": { + "!type": "fn(value: number, start?: number, end?: number)", + "!doc": "The fill() method fills all the elements of a typed array from a start index to an end index with a static value. This method has the same algorithm as Array.prototype.fill(). TypedArray is one of the typed array types here.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/fill" + }, + "filter": { + "!type": "fn(test: fn(element: number, i: number) -> bool, context?: ?) -> !this", + "!effects": ["call !0 this=!1 number number"], + "!doc": "Creates a new array with all of the elements of this array for which the provided filtering function returns true. See also Array.prototype.filter().", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/filter" + }, + "find": { + "!type": "fn(callback: fn(element: number, index: number, array: +TypedArray) -> bool, thisArg?: ?) -> number", + "!effects": ["call !0 this=!1 number number !this"], + "!doc": "The find() method returns a value in the typed array, if an element satisfies the provided testing function. Otherwise undefined is returned. TypedArray is one of the typed array types here.\nSee also the findIndex() method, which returns the index of a found element in the typed array instead of its value.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/find" + }, + "findIndex": { + "!type": "fn(callback: fn(element: number, index: number, array: +TypedArray) -> bool, thisArg?: ?) -> number", + "!effects": ["call !0 this=!1 number number !this"], + "!doc": "The findIndex() method returns an index in the typed array, if an element in the typed array satisfies the provided testing function. Otherwise -1 is returned.\nSee also the find() method, which returns the value of a found element in the typed array instead of its index.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/findIndex" + }, + "forEach": { + "!type": "fn(callback: fn(value: number, key: number, array: +TypedArray), thisArg?: ?)", + "!effects": ["call !0 this=!1 number number !this"], + "!doc": "Executes a provided function once per array element.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/forEach" + }, + "indexOf": { + "!type": "fn(searchElement: number, fromIndex?: number) -> number", + "!doc": "The indexOf() method returns the first index at which a given element can be found in the typed array, or -1 if it is not present. This method has the same algorithm as Array.prototype.indexOf(). TypedArray is one of the typed array types here.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/indexOf" + }, + "join": { + "!type": "fn(separator?: string) -> string", + "!doc": "The join() method joins all elements of an array into a string. This method has the same algorithm as Array.prototype.join(). TypedArray is one of the typed array types here.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/join" + }, + "keys": { + "!type": "fn() -> +iter[:t=number]", + "!doc": "The keys() method returns a new Array Iterator object that contains the keys for each index in the array.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/keys" + }, + "lastIndexOf": { + "!type": "fn(searchElement: number, fromIndex?: number) -> number", + "!doc": "The lastIndexOf() method returns the last index at which a given element can be found in the typed array, or -1 if it is not present. The typed array is searched backwards, starting at fromIndex. This method has the same algorithm as Array.prototype.lastIndexOf(). TypedArray is one of the typed array types here.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/lastIndexOf" + }, + "length": { + "!type": "number", + "!doc": "Returns the number of elements hold in the typed array. Fixed at construction time and thus read only.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/length" + }, + "map": { + "!type": "fn(f: fn(element: number, i: number) -> number, context?: ?) -> +TypedArray", + "!effects": ["call !0 this=!1 number number"], + "!doc": "Creates a new array with the results of calling a provided function on every element in this array. See also Array.prototype.map().", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/map" + }, + "reduce": { + "!type": "fn(combine: fn(sum: ?, elt: number, i: number) -> ?, init?: ?) -> !0.!ret", + "!effects": ["call !0 !1 number number"], + "!doc": "Apply a function against an accumulator and each value of the array (from left-to-right) as to reduce it to a single value. See also Array.prototype.reduce().", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/reduce" + }, + "reduceRight": { + "!type": "fn(combine: fn(sum: ?, elt: number, i: number) -> ?, init?: ?) -> !0.!ret", + "!effects": ["call !0 !1 number number"], + "!doc": "Apply a function against an accumulator and each value of the array (from right-to-left) as to reduce it to a single value. See also Array.prototype.reduceRight().", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/reduceRight" + }, + "reverse": { + "!type": "fn()", + "!doc": "The reverse() method reverses a typed array in place. The first typed array element becomes the last and the last becomes the first. This method has the same algorithm as Array.prototype.reverse(). TypedArray is one of the typed array types here.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/reverse" + }, + "set": { + "!type": "fn(array: [number], offset?: number)", + "!doc": "The set() method stores multiple values in the typed array, reading input values from a specified array.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/set" + }, + "slice": { + "!type": "fn(from: number, to?: number) -> +TypedArray", + "!doc": "Extracts a section of an array and returns a new array. See also Array.prototype.slice().", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/slice" + }, + "some": { + "!type": "fn(test: fn(elt: number, i: number) -> bool, context?: ?) -> bool", + "!effects": ["call !0 this=!1 number number"], + "!doc": "The some() method tests whether some element in the typed array passes the test implemented by the provided function. This method has the same algorithm as Array.prototype.some(). TypedArray is one of the typed array types here.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/some" + }, + "sort": { + "!type": "fn(compare?: fn(a: number, b: number) -> number)", + "!effects": ["call !0 number number"], + "!doc": "Sorts the elements of an array in place and returns the array. See also Array.prototype.sort().", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/sort" + }, + "subarray": { + "!type": "fn(begin?: number, end?: number) -> +TypedArray", + "!doc": "The subarray() method returns a new TypedArray on the same ArrayBuffer store and with the same element types as for this TypedArray object. The begin offset is inclusive and the end offset is exclusive. TypedArray is one of the typed array types.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/subarray" + }, + "values": { + "!type": "fn() -> +iter[:t=number]", + "!doc": "The values() method returns a new Array Iterator object that contains the values for each index in the array.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/values" + }, + ":Symbol.iterator": { + "!type": "fn() -> +iter[:t=number]", + "!doc": "Returns a new Array Iterator object that contains the values for each index in the array.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/@@iterator" + } + } } }, "Infinity": { @@ -28,16 +264,41 @@ }, "Object": { "!type": "fn()", + "getPrototypeOf": { + "!type": "fn(obj: ?) -> ?", + "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Object/getPrototypeOf", + "!doc": "Returns the prototype (i.e. the internal prototype) of the specified object." + }, "create": { "!type": "fn(proto: ?) -> !custom:Object_create", "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Object/create", "!doc": "Creates a new object with the specified prototype object and properties." }, + "defineProperty": { + "!type": "fn(obj: ?, prop: string, desc: propertyDescriptor) -> !custom:Object_defineProperty", + "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Object/defineProperty", + "!doc": "Defines a new property directly on an object, or modifies an existing property on an object, and returns the object. If you want to see how to use the Object.defineProperty method with a binary-flags-like syntax, see this article." + }, + "defineProperties": { + "!type": "fn(obj: ?, props: ?) -> !custom:Object_defineProperties", + "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Object/defineProperty", + "!doc": "Defines a new property directly on an object, or modifies an existing property on an object, and returns the object. If you want to see how to use the Object.defineProperty method with a binary-flags-like syntax, see this article." + }, + "getOwnPropertyDescriptor": { + "!type": "fn(obj: ?, prop: string) -> propertyDescriptor", + "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Object/getOwnPropertyDescriptor", + "!doc": "Returns a property descriptor for an own property (that is, one directly present on an object, not present by dint of being along an object's prototype chain) of a given object." + }, "keys": { "!type": "fn(obj: ?) -> [string]", "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Object/keys", "!doc": "Returns an array of a given object's own enumerable properties, in the same order as that provided by a for-in loop (the difference being that a for-in loop enumerates properties in the prototype chain as well)." }, + "getOwnPropertyNames": { + "!type": "fn(obj: ?) -> [string]", + "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Object/getOwnPropertyNames", + "!doc": "Returns an array of all properties (enumerable or not) found directly upon a given object." + }, "seal": { "!type": "fn(obj: ?)", "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Object/seal", @@ -74,11 +335,21 @@ "!doc": "The Object.assign() method is used to copy the values of all enumerable own properties from one or more source objects to a target object. It will return the target object.", "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign" }, + "getOwnPropertySymbols": { + "!type": "fn(obj: ?) -> !custom:getOwnPropertySymbols", + "!doc": "The Object.getOwnPropertySymbols() method returns an array of all symbol properties found directly upon a given object.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertySymbols" + }, "is": { "!type": "fn(value1: ?, value2: ?) -> bool", "!doc": "The Object.is() method determines whether two values are the same value.", "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is" }, + "setPrototypeOf": { + "!type": "fn(obj: ?, prototype: ?)", + "!doc": "The Object.setPrototype() method sets the prototype (i.e., the internal [[Prototype]] property) of a specified object to another object or null.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/setPrototypeOf" + }, "prototype": { "!stdProto": "Object", "toString": { @@ -883,6 +1154,59 @@ "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Date", "!doc": "Creates JavaScript Date instances which let you work with dates and times." }, + "Error": { + "!type": "fn(message: string)", + "prototype": { + "name": { + "!type": "string", + "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Error/name", + "!doc": "A name for the type of error." + }, + "message": { + "!type": "string", + "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Error/message", + "!doc": "A human-readable description of the error." + } + }, + "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Error", + "!doc": "Creates an error object." + }, + "SyntaxError": { + "!type": "fn(message: string)", + "prototype": "Error.prototype", + "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/SyntaxError", + "!doc": "Represents an error when trying to interpret syntactically invalid code." + }, + "ReferenceError": { + "!type": "fn(message: string)", + "prototype": "Error.prototype", + "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/ReferenceError", + "!doc": "Represents an error when a non-existent variable is referenced." + }, + "URIError": { + "!type": "fn(message: string)", + "prototype": "Error.prototype", + "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/URIError", + "!doc": "Represents an error when a malformed URI is encountered." + }, + "EvalError": { + "!type": "fn(message: string)", + "prototype": "Error.prototype", + "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/EvalError", + "!doc": "Represents an error regarding the eval function." + }, + "RangeError": { + "!type": "fn(message: string)", + "prototype": "Error.prototype", + "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/RangeError", + "!doc": "Represents an error when a number is not within the correct range allowed." + }, + "TypeError": { + "!type": "fn(message: string)", + "prototype": "Error.prototype", + "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/TypeError", + "!doc": "Represents an error an error when a value is not of the expected type." + }, "parseInt": { "!type": "fn(string: string, radix?: number) -> number", "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/parseInt", @@ -903,6 +1227,11 @@ "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/isFinite", "!doc": "Determines whether the passed value is a finite number." }, + "eval": { + "!type": "fn(code: string) -> ?", + "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/eval", + "!doc": "Evaluates JavaScript code represented as a string." + }, "encodeURI": { "!type": "fn(uri: string) -> string", "!url": "https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/encodeURI", @@ -1156,6 +1485,135 @@ "!url": "https://developer.mozilla.org/en-US/docs/JSON", "!doc": "JSON (JavaScript Object Notation) is a data-interchange format. It closely resembles a subset of JavaScript syntax, although it is not a strict subset. (See JSON in the JavaScript Reference for full details.) It is useful when writing any kind of JavaScript-based application, including websites and browser extensions. For example, you might store user information in JSON format in a cookie, or you might store extension preferences in JSON in a string-valued browser preference." }, + "ArrayBuffer": { + "!type": "fn(length: number)", + "!doc": "The ArrayBuffer object is used to represent a generic, fixed-length raw binary data buffer.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer", + "isView": { + "!type": "fn(arg: +ArrayBuffer) -> bool", + "!doc": "The ArrayBuffer.isView() method returns true if arg is one of the ArrayBuffer views, such as typed array objects or a DataView; false otherwise.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer/isView" + }, + "prototype": { + "byteLength": { + "!type": "number", + "!doc": "The byteLength accessor property represents the length of an ArrayBuffer in bytes.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer/byteLength" + }, + "slice": { + "!type": "fn(begin: number, end?: number) -> +ArrayBuffer", + "!doc": "The slice() method returns a new ArrayBuffer whose contents are a copy of this ArrayBuffer's bytes from begin, inclusive, up to end, exclusive.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer/slice" + } + } + }, + "DataView": { + "!type": "fn(buffer: +ArrayBuffer, byteOffset?: number, byteLength?: number)", + "!doc": "The DataView view provides a low-level interface for reading data from and writing it to an ArrayBuffer.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView", + "prototype": { + "buffer": { + "!type": "+ArrayBuffer", + "!doc": "The buffer accessor property represents the ArrayBuffer referenced by the DataView at construction time.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/buffer" + }, + "byteLength": { + "!type": "number", + "!doc": "The byteLength accessor property represents the length (in bytes) of this view from the start of its ArrayBuffer.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/byteLength" + }, + "byteOffset": { + "!type": "number", + "!doc": "The byteOffset accessor property represents the offset (in bytes) of this view from the start of its ArrayBuffer.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/byteOffset" + }, + "getFloat32": { + "!type": "fn(byteOffset: number, littleEndian?: bool) -> number", + "!doc": "The getFloat32() method gets a signed 32-bit integer (float) at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/getFloat32" + }, + "getFloat64": { + "!type": "fn(byteOffset: number, littleEndian?: bool) -> number", + "!doc": "The getFloat64() method gets a signed 64-bit float (double) at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/getFloat64" + }, + "getInt16": { + "!type": "fn(byteOffset: number, littleEndian?: bool) -> number", + "!doc": "The getInt16() method gets a signed 16-bit integer (short) at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/getInt16" + }, + "getInt32": { + "!type": "fn(byteOffset: number, littleEndian?: bool) -> number", + "!doc": "The getInt32() method gets a signed 32-bit integer (long) at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/getInt32" + }, + "getInt8": { + "!type": "fn(byteOffset: number, littleEndian?: bool) -> number", + "!doc": "The getInt8() method gets a signed 8-bit integer (byte) at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/getInt8" + }, + "getUint16": { + "!type": "fn(byteOffset: number, littleEndian?: bool) -> number", + "!doc": "The getUint16() method gets an unsigned 16-bit integer (unsigned short) at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/getUint16" + }, + "getUint32": { + "!type": "fn(byteOffset: number, littleEndian?: bool) -> number", + "!doc": "The getUint32() method gets an unsigned 32-bit integer (unsigned long) at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/getUint32" + }, + "getUint8": { + "!type": "fn(byteOffset: number) -> number", + "!doc": "The getUint8() method gets an unsigned 8-bit integer (unsigned byte) at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/getUint8" + }, + "setFloat32": { + "!type": "fn(byteOffset: number, value: number, littleEndian?: bool)", + "!doc": "The setFloat32() method stores a signed 32-bit integer (float) value at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/setFloat32" + }, + "setFloat64": { + "!type": "fn(byteOffset: number, value: number, littleEndian?: bool)", + "!doc": "The setFloat64() method stores a signed 64-bit integer (double) value at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/setFloat64" + }, + "setInt16": { + "!type": "fn(byteOffset: number, value: number, littleEndian?: bool)", + "!doc": "The setInt16() method stores a signed 16-bit integer (short) value at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/setInt16" + }, + "setInt32": { + "!type": "fn(byteOffset: number, value: number, littleEndian?: bool)", + "!doc": "The setInt32() method stores a signed 32-bit integer (long) value at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/setInt32" + }, + "setInt8": { + "!type": "fn(byteOffset: number, value: number)", + "!doc": "The setInt8() method stores a signed 8-bit integer (byte) value at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/setInt8" + }, + "setUint16": { + "!type": "fn(byteOffset: number, value: number, littleEndian?: bool)", + "!doc": "The setUint16() method stores an unsigned 16-bit integer (unsigned short) value at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/setUint16" + }, + "setUint32": { + "!type": "fn(byteOffset: number, value: number, littleEndian?: bool)", + "!doc": "The setUint32() method stores an unsigned 32-bit integer (unsigned long) value at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/setUint32" + }, + "setUint8": { + "!type": "fn(byteOffset: number, value: number)", + "!doc": "The setUint8() method stores an unsigned 8-bit integer (byte) value at the specified byte offset from the start of the DataView.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/setUint8" + } + } + }, + "Float32Array": "TypedArray", + "Float64Array": "TypedArray", + "Int16Array": "TypedArray", + "Int32Array": "TypedArray", + "Int8Array": "TypedArray", "Map": { "!type": "fn(iterable?: [?])", "!doc": "The Map object is a simple key/value map. Any value (both objects and primitive values) may be used as either a key or a value.", @@ -1220,6 +1678,112 @@ } } }, + "Promise": { + "!type": "fn(executor: fn(resolve: fn(value: ?), reject: fn(reason: ?))) -> !custom:Promise_ctor", + "!doc": "The Promise object is used for deferred and asynchronous computations. A Promise is in one of the three states:", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise", + "all": { + "!type": "fn(iterable: [+Promise]) -> +Promise[:t=[!0..:t]]", + "!doc": "The Promise.all(iterable) method returns a promise that resolves when all of the promises in the iterable argument have resolved.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all" + }, + "race": { + "!type": "fn(iterable: [+Promise]) -> !0.", + "!doc": "The Promise.race(iterable) method returns a promise that resolves or rejects as soon as one of the promises in the iterable resolves or rejects, with the value or reason from that promise.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race" + }, + "reject": "Promise_reject", + "resolve": { + "!type": "fn(value: ?) -> !custom:Promise_resolve", + "!doc": "The Promise.resolve(value) method returns a Promise object that is resolved with the given value. If the value is a thenable (i.e. has a then method), the returned promise will 'follow' that thenable, adopting its eventual state; otherwise the returned promise will be fulfilled with the value.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/resolve" + }, + "prototype": "Promise.prototype" + }, + "Proxy": { + "!type": "fn(target: ?, handler: Proxy_handler)", + "!doc": "The Proxy object is used to define the custom behavior in JavaScript fundamental operation (e.g. property lookup, assignment, enumeration, function invocation, etc).", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy", + "revocable": { + "!type": "fn(target: ?, handler: Proxy_handler) -> Proxy_revocable", + "!doc": "The Proxy.revocable() method is used to create a revocable Proxy object.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/revocable" + } + }, + "Reflect": { + "!doc": "Reflect is a built-in object that provides methods for interceptable JavaScript operations.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect", + "apply": { + "!type": "fn(target: fn(), thisArg?: ?, argumentList?: [?]) -> !0.!ret", + "!doc": "Calls a target function with arguments as specified.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/apply" + }, + "construct": { + "!type": "fn(target: fn(), argumentList?: [?]) -> ?", + "!doc": "Acts like the new operator as a function. It is equivalent to calling new target(...args).", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/construct" + }, + "defineProperty": { + "!type": "fn(target: ?, property: string, descriptor: propertyDescriptor) -> bool", + "!doc": "The static Reflect.defineProperty() method is like Object.defineProperty() but returns a Boolean.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/defineProperty" + }, + "deleteProperty": { + "!type": "fn(target: ?, property: string) -> bool", + "!doc": "Works like the delete operator as a function.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/deleteProperty" + }, + "enumerate": { + "!type": "fn(target: ?) -> +iter[:t=string]", + "!doc": "Returns an iterator with the enumerable own and inherited properties of the target object.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/enumerate" + }, + "get": { + "!type": "fn(target: ?, property: string) -> ?", + "!doc": "Gets a property from an object.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/get" + }, + "getOwnPropertyDescriptor": { + "!type": "fn(target: ?, property: string) -> ?", + "!doc": "Returns a property descriptor of the given property if it exists on the object, undefined otherwise.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/getOwnPropertyDescriptor" + }, + "getPrototypeOf": { + "!type": "fn(target: ?) -> ?", + "!doc": "Returns the prototype of the specified object.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/getPrototypeOf" + }, + "has": { + "!type": "fn(target: ?, property: string) -> bool", + "!doc": "The static Reflect.has() method works like the in operator as a function.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/has" + }, + "isExtensible": { + "!type": "fn(target: ?) -> bool", + "!doc": "Determines if an object is extensible (whether it can have new properties added to it).", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/isExtensible" + }, + "ownKeys": { + "!type": "fn(target: ?) -> [string]", + "!doc": "Returns an array of the target object's own property keys.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/ownKeys" + }, + "preventExtensions": { + "!type": "fn(target: ?) -> bool", + "!doc": "Prevents new properties from ever being added to an object.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/preventExtensions" + }, + "set": { + "!type": "fn(target: ?, property: string, value: ?) -> bool", + "!doc": "Set a property on an object.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/set" + }, + "setPrototypeOf": { + "!type": "fn(target: ?, prototype: ?) -> bool", + "!doc": "Sets the prototype of a specified object to another object or to null.", + "!url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/setPrototypeOf" + } + }, "Set": { "!type": "fn(iterable?: [?])", "!doc": "The Set object lets you store unique values of any type, whether primitive values or object references.", @@ -1296,6 +1860,7 @@ "hasInstance": ":Symbol.hasInstance", "isConcatSpreadable": ":Symbol.isConcatSpreadable", "iterator": ":Symbol.iterator", + "keyFor": ":Symbol.keyFor", "match": ":Symbol.match", "replace": ":Symbol.replace", "search": ":Symbol.search", @@ -1307,6 +1872,10 @@ "!stdProto": "Symbol" } }, + "Uint16Array": "TypedArray", + "Uint32Array": "TypedArray", + "Uint8Array": "TypedArray", + "Uint8ClampedArray": "TypedArray", "WeakMap": { "!type": "fn(iterable?: [?])", "!doc": "The WeakMap object is a collection of key/value pairs in which the keys are objects and the values can be arbitrary values.", diff --git a/app/client/src/constants/messages.ts b/app/client/src/constants/messages.ts index c348ff83b2..a1f018353f 100644 --- a/app/client/src/constants/messages.ts +++ b/app/client/src/constants/messages.ts @@ -667,7 +667,13 @@ export const DOC_DESCRIPTION = () => export const NAV_DESCRIPTION = () => `Navigate to any page, widget or file across this project.`; -export const DOWNLOAD_FILE_NAME_ERROR = () => "File name was not provided"; +export const TRIGGER_ACTION_VALIDATION_ERROR = ( + functionName: string, + argumentName: string, + expectedType: string, + received: string, +) => + `${functionName} expected ${expectedType} for '${argumentName}' argument but received ${received}`; // Comment card tooltips export const MORE_OPTIONS = () => "More Options"; diff --git a/app/client/src/entities/DataTree/actionTriggers.ts b/app/client/src/entities/DataTree/actionTriggers.ts index 4f696de673..e6eba816a0 100644 --- a/app/client/src/entities/DataTree/actionTriggers.ts +++ b/app/client/src/entities/DataTree/actionTriggers.ts @@ -2,7 +2,6 @@ import { NavigationTargetType } from "sagas/ActionExecution/NavigateActionSaga"; import { TypeOptions } from "react-toastify"; export enum ActionTriggerType { - PROMISE = "PROMISE", RUN_PLUGIN_ACTION = "RUN_PLUGIN_ACTION", CLEAR_PLUGIN_ACTION = "CLEAR_PLUGIN_ACTION", NAVIGATE_TO = "NAVIGATE_TO", @@ -20,14 +19,22 @@ export enum ActionTriggerType { STOP_WATCHING_CURRENT_LOCATION = "STOP_WATCHING_CURRENT_LOCATION", } -export type PromiseActionDescription = { - type: ActionTriggerType.PROMISE; - payload: { - executor: ActionDescription[]; - then: string[]; - catch?: string; - finally?: string; - }; +export const ActionTriggerFunctionNames: Record = { + [ActionTriggerType.CLEAR_INTERVAL]: "clearInterval", + [ActionTriggerType.CLEAR_PLUGIN_ACTION]: "action.clear", + [ActionTriggerType.CLOSE_MODAL]: "closeModal", + [ActionTriggerType.COPY_TO_CLIPBOARD]: "copyToClipboard", + [ActionTriggerType.DOWNLOAD]: "download", + [ActionTriggerType.NAVIGATE_TO]: "navigateTo", + [ActionTriggerType.RESET_WIDGET_META_RECURSIVE_BY_NAME]: "resetWidget", + [ActionTriggerType.RUN_PLUGIN_ACTION]: "action.run", + [ActionTriggerType.SET_INTERVAL]: "setInterval", + [ActionTriggerType.SHOW_ALERT]: "showAlert", + [ActionTriggerType.SHOW_MODAL_BY_NAME]: "showModal", + [ActionTriggerType.STORE_VALUE]: "storeValue", + [ActionTriggerType.GET_CURRENT_LOCATION]: "getCurrentLocation", + [ActionTriggerType.WATCH_CURRENT_LOCATION]: "watchLocation", + [ActionTriggerType.STOP_WATCHING_CURRENT_LOCATION]: "stopWatch", }; export type RunPluginActionDescription = { @@ -35,6 +42,8 @@ export type RunPluginActionDescription = { payload: { actionId: string; params?: Record; + onSuccess?: string; + onError?: string; }; }; @@ -101,7 +110,7 @@ export type CopyToClipboardDescription = { export type ResetWidgetDescription = { type: ActionTriggerType.RESET_WIDGET_META_RECURSIVE_BY_NAME; payload: { - widgetName: string | unknown; + widgetName: string; resetChildren: boolean; }; }; @@ -128,28 +137,28 @@ type GeolocationOptions = { enableHighAccuracy?: boolean; }; +type GeolocationPayload = { + onSuccess?: string; + onError?: string; + options?: GeolocationOptions; +}; + export type GetCurrentLocationDescription = { type: ActionTriggerType.GET_CURRENT_LOCATION; - payload: { - options?: GeolocationOptions; - }; + payload: GeolocationPayload; }; export type WatchCurrentLocationDescription = { type: ActionTriggerType.WATCH_CURRENT_LOCATION; - payload: { - onSuccess: string | undefined; - onError: string | undefined; - options?: GeolocationOptions; - }; + payload: GeolocationPayload; }; export type StopWatchingCurrentLocationDescription = { type: ActionTriggerType.STOP_WATCHING_CURRENT_LOCATION; + payload?: Record; }; export type ActionDescription = - | PromiseActionDescription | RunPluginActionDescription | ClearPluginActionDescription | NavigateActionDescription diff --git a/app/client/src/entities/DataTree/dataTreeFactory.ts b/app/client/src/entities/DataTree/dataTreeFactory.ts index 45a58df50d..af1b49915d 100644 --- a/app/client/src/entities/DataTree/dataTreeFactory.ts +++ b/app/client/src/entities/DataTree/dataTreeFactory.ts @@ -21,11 +21,10 @@ import { ClearPluginActionDescription, RunPluginActionDescription, } from "entities/DataTree/actionTriggers"; -import { AppsmithPromise } from "workers/Actions"; export type ActionDispatcher = ( ...args: any[] -) => ActionDescription | AppsmithPromise; +) => Promise | ActionDescription; export enum ENTITY_TYPE { ACTION = "ACTION", diff --git a/app/client/src/sagas/ActionExecution/ActionExecutionSagas.ts b/app/client/src/sagas/ActionExecution/ActionExecutionSagas.ts index ad35fb0b1c..96d804786f 100644 --- a/app/client/src/sagas/ActionExecution/ActionExecutionSagas.ts +++ b/app/client/src/sagas/ActionExecution/ActionExecutionSagas.ts @@ -8,7 +8,7 @@ import * as log from "loglevel"; import { all, call, put, takeEvery, takeLatest } from "redux-saga/effects"; import { evaluateArgumentSaga, - evaluateDynamicTrigger, + evaluateAndExecuteDynamicTrigger, evaluateSnippetSaga, setAppVersionOnWorkerSaga, } from "sagas/EvaluationsSaga"; @@ -19,7 +19,6 @@ import copySaga from "sagas/ActionExecution/CopyActionSaga"; import resetWidgetActionSaga from "sagas/ActionExecution/ResetWidgetActionSaga"; import showAlertSaga from "sagas/ActionExecution/ShowAlertActionSaga"; import executePluginActionTriggerSaga from "sagas/ActionExecution/PluginActionSaga"; -import executePromiseSaga from "sagas/ActionExecution/PromiseActionSaga"; import { ActionDescription, ActionTriggerType, @@ -32,7 +31,8 @@ import { import AppsmithConsole from "utils/AppsmithConsole"; import { logActionExecutionError, - TriggerEvaluationError, + TriggerFailureError, + UncaughtPromiseError, } from "sagas/ActionExecution/errorUtils"; import { clearIntervalSaga, @@ -53,24 +53,22 @@ export type TriggerMeta = { * The controller saga that routes different trigger effects to its executor sagas * @param trigger The trigger information with trigger type * @param eventType Widget/Platform event which triggered this action - * @param triggerMeta Meta information about the trigger to log errors + * @param triggerMeta Where the trigger originated from */ export function* executeActionTriggers( trigger: ActionDescription, eventType: EventType, triggerMeta: TriggerMeta, -) { +): any { // when called via a promise, a trigger can return some value to be used in .then let response: unknown[] = []; switch (trigger.type) { - case ActionTriggerType.PROMISE: - yield call(executePromiseSaga, trigger.payload, eventType, triggerMeta); - break; case ActionTriggerType.RUN_PLUGIN_ACTION: response = yield call( executePluginActionTriggerSaga, trigger.payload, eventType, + triggerMeta, ); break; case ActionTriggerType.CLEAR_PLUGIN_ACTION: @@ -80,7 +78,7 @@ export function* executeActionTriggers( yield call(navigateActionSaga, trigger.payload); break; case ActionTriggerType.SHOW_ALERT: - yield call(showAlertSaga, trigger.payload, triggerMeta); + yield call(showAlertSaga, trigger.payload); break; case ActionTriggerType.SHOW_MODAL_BY_NAME: yield call(openModalSaga, trigger); @@ -92,19 +90,19 @@ export function* executeActionTriggers( yield call(storeValueLocally, trigger.payload); break; case ActionTriggerType.DOWNLOAD: - yield call(downloadSaga, trigger.payload, triggerMeta); + yield call(downloadSaga, trigger.payload); break; case ActionTriggerType.COPY_TO_CLIPBOARD: - yield call(copySaga, trigger.payload, triggerMeta); + yield call(copySaga, trigger.payload); break; case ActionTriggerType.RESET_WIDGET_META_RECURSIVE_BY_NAME: - yield call(resetWidgetActionSaga, trigger.payload, triggerMeta); + yield call(resetWidgetActionSaga, trigger.payload); break; case ActionTriggerType.SET_INTERVAL: yield call(setIntervalSaga, trigger.payload, eventType, triggerMeta); break; case ActionTriggerType.CLEAR_INTERVAL: - yield call(clearIntervalSaga, trigger.payload, triggerMeta); + yield call(clearIntervalSaga, trigger.payload); break; case ActionTriggerType.GET_CURRENT_LOCATION: response = yield call( @@ -147,23 +145,13 @@ export function* executeAppAction(payload: ExecuteTriggerPayload) { throw new Error("Executing undefined action"); } - const triggers = yield call( - evaluateDynamicTrigger, + yield call( + evaluateAndExecuteDynamicTrigger, dynamicString, + type, + { source, triggerPropertyName }, responseData, ); - - log.debug({ triggers }); - if (triggers && triggers.length) { - yield all( - triggers.map((trigger: ActionDescription) => - call(executeActionTriggers, trigger, type, { - source, - triggerPropertyName, - }), - ), - ); - } } function* initiateActionTriggerExecution( @@ -179,7 +167,7 @@ function* initiateActionTriggerExecution( event.callback({ success: true }); } } catch (e) { - if (e instanceof TriggerEvaluationError) { + if (e instanceof UncaughtPromiseError || e instanceof TriggerFailureError) { logActionExecutionError(e.message, source, triggerPropertyName); } // handle errors here diff --git a/app/client/src/sagas/ActionExecution/CopyActionSaga.ts b/app/client/src/sagas/ActionExecution/CopyActionSaga.ts index f4840aac6b..3ab4d1bc03 100644 --- a/app/client/src/sagas/ActionExecution/CopyActionSaga.ts +++ b/app/client/src/sagas/ActionExecution/CopyActionSaga.ts @@ -1,15 +1,22 @@ import copy from "copy-to-clipboard"; import AppsmithConsole from "utils/AppsmithConsole"; -import { CopyToClipboardDescription } from "entities/DataTree/actionTriggers"; -import { TriggerMeta } from "sagas/ActionExecution/ActionExecutionSagas"; -import { TriggerFailureError } from "sagas/ActionExecution/errorUtils"; +import { + ActionTriggerType, + CopyToClipboardDescription, +} from "entities/DataTree/actionTriggers"; +import { ActionValidationError } from "sagas/ActionExecution/errorUtils"; +import { getType, Types } from "utils/TypeHelpers"; export default function copySaga( payload: CopyToClipboardDescription["payload"], - triggerMeta: TriggerMeta, ) { if (typeof payload.data !== "string") { - throw new TriggerFailureError("Value to copy is not a string", triggerMeta); + throw new ActionValidationError( + ActionTriggerType.COPY_TO_CLIPBOARD, + "data", + Types.STRING, + getType(payload.data), + ); } const result = copy(payload.data, payload.options); if (result) { diff --git a/app/client/src/sagas/ActionExecution/DownloadActionSaga.ts b/app/client/src/sagas/ActionExecution/DownloadActionSaga.ts index 78744bb583..a9a87e0f74 100644 --- a/app/client/src/sagas/ActionExecution/DownloadActionSaga.ts +++ b/app/client/src/sagas/ActionExecution/DownloadActionSaga.ts @@ -1,21 +1,23 @@ -import { createMessage, DOWNLOAD_FILE_NAME_ERROR } from "constants/messages"; import { getType, isURL, Types } from "utils/TypeHelpers"; import downloadjs from "downloadjs"; import AppsmithConsole from "utils/AppsmithConsole"; import Axios from "axios"; -import { DownloadActionDescription } from "entities/DataTree/actionTriggers"; -import { TriggerMeta } from "sagas/ActionExecution/ActionExecutionSagas"; -import { TriggerFailureError } from "sagas/ActionExecution/errorUtils"; +import { + ActionTriggerType, + DownloadActionDescription, +} from "entities/DataTree/actionTriggers"; +import { ActionValidationError } from "sagas/ActionExecution/errorUtils"; export default async function downloadSaga( action: DownloadActionDescription["payload"], - triggerMeta: TriggerMeta, ) { const { data, name, type } = action; if (!name) { - throw new TriggerFailureError( - createMessage(DOWNLOAD_FILE_NAME_ERROR), - triggerMeta, + throw new ActionValidationError( + ActionTriggerType.DOWNLOAD, + "name", + Types.STRING, + getType(name), ); } const dataType = getType(data); diff --git a/app/client/src/sagas/ActionExecution/GetCurrentLocationSaga.ts b/app/client/src/sagas/ActionExecution/GetCurrentLocationSaga.ts index 76a0aa5a05..233c36f765 100644 --- a/app/client/src/sagas/ActionExecution/GetCurrentLocationSaga.ts +++ b/app/client/src/sagas/ActionExecution/GetCurrentLocationSaga.ts @@ -8,7 +8,10 @@ import { TriggerMeta, } from "sagas/ActionExecution/ActionExecutionSagas"; import { call, put, spawn, take } from "redux-saga/effects"; -import { TriggerFailureError } from "sagas/ActionExecution/errorUtils"; +import { + logActionExecutionError, + TriggerFailureError, +} from "sagas/ActionExecution/errorUtils"; import { setUserCurrentGeoLocation } from "actions/browserRequestActions"; import { Channel, channel } from "redux-saga"; @@ -115,7 +118,11 @@ export function* getCurrentLocationSaga( yield put(setUserCurrentGeoLocation(currentLocation)); return [currentLocation]; } catch (e) { - throw new TriggerFailureError(e.message, triggerMeta, e); + logActionExecutionError( + e.message, + triggerMeta.source, + triggerMeta.triggerPropertyName, + ); } } @@ -128,9 +135,10 @@ export function* watchCurrentLocation( if (watchId) { // When a watch is already active, we will not start a new watch. // at a given point in time, only one watch is active - throw new TriggerFailureError( + logActionExecutionError( "A watchLocation is already active. Clear it before before starting a new one", - triggerMeta, + triggerMeta.source, + triggerMeta.triggerPropertyName, ); } successChannel = channel(); @@ -167,7 +175,12 @@ export function* stopWatchCurrentLocation( triggerMeta: TriggerMeta, ) { if (watchId === undefined) { - throw new TriggerFailureError("No location watch active", triggerMeta); + logActionExecutionError( + "No location watch active", + triggerMeta.source, + triggerMeta.triggerPropertyName, + ); + return; } navigator.geolocation.clearWatch(watchId); watchId = undefined; diff --git a/app/client/src/sagas/ActionExecution/ModalSagas.ts b/app/client/src/sagas/ActionExecution/ModalSagas.ts index 948ab25000..281403b664 100644 --- a/app/client/src/sagas/ActionExecution/ModalSagas.ts +++ b/app/client/src/sagas/ActionExecution/ModalSagas.ts @@ -1,17 +1,41 @@ import { + ActionTriggerType, CloseModalActionDescription, ShowModalActionDescription, } from "entities/DataTree/actionTriggers"; import { put } from "redux-saga/effects"; import AppsmithConsole from "utils/AppsmithConsole"; +import { ActionValidationError } from "sagas/ActionExecution/errorUtils"; +import { getType, Types } from "utils/TypeHelpers"; export function* openModalSaga(action: ShowModalActionDescription) { + const { modalName } = action.payload; + if (typeof modalName !== "string") { + throw new ActionValidationError( + ActionTriggerType.SHOW_MODAL_BY_NAME, + "name", + Types.STRING, + getType(modalName), + ); + } yield put(action); + AppsmithConsole.info({ + text: `openModal(${modalName}) was triggered`, + }); } export function* closeModalSaga(action: CloseModalActionDescription) { + const { modalName } = action.payload; + if (typeof modalName !== "string") { + throw new ActionValidationError( + ActionTriggerType.CLOSE_MODAL, + "name", + Types.STRING, + getType(modalName), + ); + } yield put(action); AppsmithConsole.info({ - text: `closeModal(${action.payload.modalName}) was triggered`, + text: `closeModal(${modalName}) was triggered`, }); } diff --git a/app/client/src/sagas/ActionExecution/PluginActionSaga.ts b/app/client/src/sagas/ActionExecution/PluginActionSaga.ts index 3ef1f12aa2..733314ca55 100644 --- a/app/client/src/sagas/ActionExecution/PluginActionSaga.ts +++ b/app/client/src/sagas/ActionExecution/PluginActionSaga.ts @@ -82,17 +82,25 @@ import { QUERIES_EDITOR_URL, } from "constants/routes"; import { SAAS_EDITOR_API_ID_URL } from "pages/Editor/SaaSEditor/constants"; -import { RunPluginActionDescription } from "entities/DataTree/actionTriggers"; +import { + ActionTriggerType, + RunPluginActionDescription, +} from "entities/DataTree/actionTriggers"; import { APP_MODE } from "entities/App"; import { FileDataTypes } from "widgets/constants"; import { hideDebuggerErrors } from "actions/debuggerActions"; import { - PluginTriggerFailureError, - PluginActionExecutionError, - UserCancelledActionExecutionError, + ActionValidationError, getErrorAsString, + PluginActionExecutionError, + PluginTriggerFailureError, + UserCancelledActionExecutionError, } from "sagas/ActionExecution/errorUtils"; import { trimQueryString } from "utils/helpers"; +import { + executeAppAction, + TriggerMeta, +} from "sagas/ActionExecution/ActionExecutionSagas"; enum ActionResponseDataTypes { BINARY = "BINARY", @@ -242,8 +250,17 @@ function* confirmRunActionSaga() { export default function* executePluginActionTriggerSaga( pluginAction: RunPluginActionDescription["payload"], eventType: EventType, + triggerMeta: TriggerMeta, ) { - const { actionId, params } = pluginAction; + const { actionId, onError, onSuccess, params } = pluginAction; + if (getType(params) !== Types.OBJECT) { + throw new ActionValidationError( + ActionTriggerType.RUN_PLUGIN_ACTION, + "params", + Types.OBJECT, + getType(params), + ); + } PerformanceTracker.startAsyncTracking( PerformanceTransactionName.EXECUTE_ACTION, { @@ -299,16 +316,29 @@ export default function* executePluginActionTriggerSaga( state: payload.request, messages: [ { - message: payload.body as string, + // Need to stringify cause this gets rendered directly + // and rendering objects can crash the app + message: !isString(payload.body) + ? JSON.stringify(payload.body) + : payload.body, type: PLATFORM_ERROR.PLUGIN_EXECUTION, subType: payload.errorType, }, ], }); - throw new PluginTriggerFailureError( - createMessage(ERROR_PLUGIN_ACTION_EXECUTE, action.name), - [payload.body, params], - ); + if (onError) { + yield call(executeAppAction, { + event: { type: eventType }, + dynamicString: onError, + responseData: [payload.body, params], + ...triggerMeta, + }); + } else { + throw new PluginTriggerFailureError( + createMessage(ERROR_PLUGIN_ACTION_EXECUTE, action.name), + [payload.body, params], + ); + } } else { AppsmithConsole.info({ logType: LOG_TYPE.ACTION_EXECUTION_SUCCESS, @@ -324,6 +354,14 @@ export default function* executePluginActionTriggerSaga( request: payload.request, }, }); + if (onSuccess) { + yield call(executeAppAction, { + event: { type: eventType }, + dynamicString: onSuccess, + responseData: [payload.body, params], + ...triggerMeta, + }); + } } return [payload.body, params]; } @@ -419,7 +457,7 @@ function* runActionSaga( } // Error should be readable error if present. - // Otherwise payload's body. + // Otherwise, payload's body. // Default to "An unexpected error occurred" if none is available const readableError = payload.readableError diff --git a/app/client/src/sagas/ActionExecution/PromiseActionSaga.ts b/app/client/src/sagas/ActionExecution/PromiseActionSaga.ts deleted file mode 100644 index 0d428a57c9..0000000000 --- a/app/client/src/sagas/ActionExecution/PromiseActionSaga.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { AppsmithPromisePayload } from "workers/Actions"; -import { - executeActionTriggers, - executeAppAction, - TriggerMeta, -} from "sagas/ActionExecution/ActionExecutionSagas"; -import { all, call } from "redux-saga/effects"; -import { EventType } from "constants/AppsmithActionConstants/ActionConstants"; -import log from "loglevel"; -import { - logActionExecutionError, - PluginTriggerFailureError, - UserCancelledActionExecutionError, -} from "sagas/ActionExecution/errorUtils"; - -export default function* executePromiseSaga( - trigger: AppsmithPromisePayload, - eventType: EventType, - triggerMeta: TriggerMeta, -): any { - try { - const responses = yield all( - trigger.executor.map((executionTrigger) => - call(executeActionTriggers, executionTrigger, eventType, triggerMeta), - ), - ); - if (trigger.then) { - if (trigger.then.length) { - let responseData: unknown[] = [{}]; - if (responses.length === 1) { - responseData = responses[0]; - } - const thenArguments = responseData || [{}]; - for (const thenable of trigger.then) { - responseData = yield call(executeAppAction, { - dynamicString: thenable, - event: { - type: eventType, - }, - responseData: thenArguments, - source: triggerMeta.source, - triggerPropertyName: triggerMeta.triggerPropertyName, - }); - } - } - } - } catch (e) { - if (e instanceof UserCancelledActionExecutionError) { - // Let this pass to finally clause - } else if (trigger.catch) { - let responseData = [e.message]; - if (e instanceof PluginTriggerFailureError) { - responseData = e.responseData; - } - const catchArguments = responseData || [{}]; - - yield call(executeAppAction, { - dynamicString: trigger.catch, - event: { - type: eventType, - }, - responseData: catchArguments, - source: triggerMeta.source, - triggerPropertyName: triggerMeta.triggerPropertyName, - }); - } else { - log.error(e); - /* Logging the error instead of throwing an error as it was making the ui to go into a loading states */ - logActionExecutionError( - e.message, - triggerMeta.source, - triggerMeta.triggerPropertyName, - ); - } - } - - if (trigger.finally) { - yield call(executeAppAction, { - dynamicString: trigger.finally, - event: { - type: eventType, - }, - responseData: [{}], - source: triggerMeta.source, - triggerPropertyName: triggerMeta.triggerPropertyName, - }); - } -} diff --git a/app/client/src/sagas/ActionExecution/ResetWidgetActionSaga.ts b/app/client/src/sagas/ActionExecution/ResetWidgetActionSaga.ts index ef7d971189..7270db7167 100644 --- a/app/client/src/sagas/ActionExecution/ResetWidgetActionSaga.ts +++ b/app/client/src/sagas/ActionExecution/ResetWidgetActionSaga.ts @@ -5,27 +5,32 @@ import { resetWidgetMetaProperty, } from "actions/metaActions"; import AppsmithConsole from "utils/AppsmithConsole"; -import { ResetWidgetDescription } from "entities/DataTree/actionTriggers"; -import { TriggerMeta } from "sagas/ActionExecution/ActionExecutionSagas"; -import { TriggerFailureError } from "sagas/ActionExecution/errorUtils"; +import { + ActionTriggerType, + ResetWidgetDescription, +} from "entities/DataTree/actionTriggers"; +import { + ActionValidationError, + TriggerFailureError, +} from "sagas/ActionExecution/errorUtils"; +import { getType, Types } from "utils/TypeHelpers"; export default function* resetWidgetActionSaga( payload: ResetWidgetDescription["payload"], - triggerMeta: TriggerMeta, ) { - if (typeof payload.widgetName !== "string") { - throw new TriggerFailureError( - "widgetName needs to be a string", - triggerMeta, + const { widgetName } = payload; + if (getType(widgetName) !== Types.STRING) { + throw new ActionValidationError( + ActionTriggerType.RESET_WIDGET_META_RECURSIVE_BY_NAME, + "widgetName", + Types.STRING, + getType(widgetName), ); } - const widget = yield select(getWidgetByName, payload.widgetName); + const widget = yield select(getWidgetByName, widgetName); if (!widget) { - throw new TriggerFailureError( - `widget ${payload.widgetName} not found`, - triggerMeta, - ); + throw new TriggerFailureError(`Widget ${payload.widgetName} not found`); } yield put(resetWidgetMetaProperty(widget.widgetId)); diff --git a/app/client/src/sagas/ActionExecution/SetIntervalSaga.ts b/app/client/src/sagas/ActionExecution/SetIntervalSaga.ts index a622ad92dd..4a77ea0994 100644 --- a/app/client/src/sagas/ActionExecution/SetIntervalSaga.ts +++ b/app/client/src/sagas/ActionExecution/SetIntervalSaga.ts @@ -74,12 +74,10 @@ function* executeInIntervals( export function* clearIntervalSaga( payload: ClearIntervalDescription["payload"], - triggerMeta: TriggerMeta, ) { if (!(payload.id in activeTimers)) { throw new TriggerFailureError( `Failed to clear interval. No timer active with id "${payload.id}"`, - triggerMeta, ); } delete activeTimers[payload.id]; diff --git a/app/client/src/sagas/ActionExecution/ShowAlertActionSaga.ts b/app/client/src/sagas/ActionExecution/ShowAlertActionSaga.ts index 14c65a51aa..0a073ef4c5 100644 --- a/app/client/src/sagas/ActionExecution/ShowAlertActionSaga.ts +++ b/app/client/src/sagas/ActionExecution/ShowAlertActionSaga.ts @@ -1,18 +1,25 @@ import { ToastTypeOptions, Variant } from "components/ads/common"; import { Toaster } from "components/ads/Toast"; import AppsmithConsole from "utils/AppsmithConsole"; -import { ShowAlertActionDescription } from "entities/DataTree/actionTriggers"; -import { TriggerMeta } from "sagas/ActionExecution/ActionExecutionSagas"; -import { TriggerFailureError } from "sagas/ActionExecution/errorUtils"; +import { + ActionTriggerType, + ShowAlertActionDescription, +} from "entities/DataTree/actionTriggers"; +import { + ActionValidationError, + TriggerFailureError, +} from "sagas/ActionExecution/errorUtils"; +import { getType, Types } from "utils/TypeHelpers"; export default function* showAlertSaga( payload: ShowAlertActionDescription["payload"], - triggerMeta: TriggerMeta, ) { if (typeof payload.message !== "string") { - throw new TriggerFailureError( - "Toast message needs to be a string", - triggerMeta, + throw new ActionValidationError( + ActionTriggerType.SHOW_ALERT, + "message", + Types.STRING, + getType(payload.message), ); } let variant; @@ -35,7 +42,6 @@ export default function* showAlertSaga( `Toast type needs to be a one of ${Object.values(ToastTypeOptions).join( ", ", )}`, - triggerMeta, ); } Toaster.show({ diff --git a/app/client/src/sagas/ActionExecution/errorUtils.ts b/app/client/src/sagas/ActionExecution/errorUtils.ts index cc57d4ca9c..9b60bacb05 100644 --- a/app/client/src/sagas/ActionExecution/errorUtils.ts +++ b/app/client/src/sagas/ActionExecution/errorUtils.ts @@ -1,14 +1,22 @@ -import { TriggerMeta } from "sagas/ActionExecution/ActionExecutionSagas"; import { TriggerSource } from "constants/AppsmithActionConstants/ActionConstants"; import { PropertyEvaluationErrorType } from "utils/DynamicBindingUtils"; import AppsmithConsole from "utils/AppsmithConsole"; import LOG_TYPE from "entities/AppsmithConsole/logtype"; -import { createMessage, DEBUGGER_TRIGGER_ERROR } from "constants/messages"; +import { + createMessage, + DEBUGGER_TRIGGER_ERROR, + TRIGGER_ACTION_VALIDATION_ERROR, +} from "constants/messages"; import { ENTITY_TYPE } from "entities/AppsmithConsole"; import { Toaster } from "components/ads/Toast"; import { Variant } from "components/ads/common"; import { ApiResponse } from "api/ApiResponses"; import { isString } from "lodash"; +import { Types } from "utils/TypeHelpers"; +import { + ActionTriggerFunctionNames, + ActionTriggerType, +} from "entities/DataTree/actionTriggers"; /* * The base trigger error that also logs the errors in the debugger. @@ -17,13 +25,36 @@ import { isString } from "lodash"; export class TriggerFailureError extends Error { error?: Error; - constructor(reason: string, triggerMeta: TriggerMeta, error?: Error) { + constructor(reason: string, error?: Error) { super(reason); this.error = error; - const { source, triggerPropertyName } = triggerMeta; - const errorMessage = error?.message || reason; + } +} - logActionExecutionError(errorMessage, source, triggerPropertyName); +export class PluginTriggerFailureError extends TriggerFailureError { + responseData: unknown[] = []; + + constructor(reason: string, responseData: unknown[]) { + super(reason); + this.responseData = responseData; + } +} + +export class ActionValidationError extends TriggerFailureError { + constructor( + functionName: ActionTriggerType, + argumentName: string, + expectedType: Types, + received: Types, + ) { + const errorMessage = createMessage( + TRIGGER_ACTION_VALIDATION_ERROR, + ActionTriggerFunctionNames[functionName], + argumentName, + expectedType, + received, + ); + super(errorMessage); } } @@ -33,40 +64,33 @@ export const logActionExecutionError = ( triggerPropertyName?: string, errorType?: PropertyEvaluationErrorType, ) => { - AppsmithConsole.addError({ - id: `${source?.id}-${triggerPropertyName}`, - logType: LOG_TYPE.TRIGGER_EVAL_ERROR, - text: createMessage(DEBUGGER_TRIGGER_ERROR, triggerPropertyName), - source: { - type: ENTITY_TYPE.WIDGET, - id: source?.id ?? "", - name: source?.name ?? "", - propertyPath: triggerPropertyName, - }, - messages: [ - { - type: errorType, - message: errorMessage, + if (triggerPropertyName) { + AppsmithConsole.addError({ + id: `${source?.id}-${triggerPropertyName}`, + logType: LOG_TYPE.TRIGGER_EVAL_ERROR, + text: createMessage(DEBUGGER_TRIGGER_ERROR, triggerPropertyName), + source: { + type: ENTITY_TYPE.WIDGET, + id: source?.id ?? "", + name: source?.name ?? "", + propertyPath: triggerPropertyName, }, - ], - }); + messages: [ + { + type: errorType, + message: errorMessage, + }, + ], + }); + } Toaster.show({ text: errorMessage, variant: Variant.danger, - showDebugButton: true, + showDebugButton: !!triggerPropertyName, }); }; -export class PluginTriggerFailureError extends Error { - responseData: unknown[] = []; - - constructor(reason: string, responseData: unknown[]) { - super(reason); - this.responseData = responseData; - } -} - /* * Thrown when action execution fails for some reason * */ @@ -94,18 +118,12 @@ export class UserCancelledActionExecutionError extends PluginActionExecutionErro } } -export class TriggerEvaluationError extends Error { +export class UncaughtPromiseError extends Error { constructor(message: string) { super(message); } } -export class UncaughtAppsmithPromiseError extends TriggerFailureError { - constructor(message: string, triggerMeta: TriggerMeta, error: Error) { - super(message, triggerMeta, error); - } -} - export const getErrorAsString = (error: unknown): string => { return isString(error) ? error : JSON.stringify(error); }; diff --git a/app/client/src/sagas/EvaluationsSaga.ts b/app/client/src/sagas/EvaluationsSaga.ts index 3836793d31..7ac768be52 100644 --- a/app/client/src/sagas/EvaluationsSaga.ts +++ b/app/client/src/sagas/EvaluationsSaga.ts @@ -1,11 +1,12 @@ import { actionChannel, + all, call, fork, put, select, + spawn, take, - all, delay, } from "redux-saga/effects"; @@ -57,7 +58,10 @@ import { setEvaluatedSnippet, setGlobalSearchFilterContext, } from "actions/globalSearchActions"; -import { executeActionTriggers } from "./ActionExecution/ActionExecutionSagas"; +import { + executeActionTriggers, + TriggerMeta, +} from "./ActionExecution/ActionExecutionSagas"; import { EventType } from "constants/AppsmithActionConstants/ActionConstants"; import { Toaster } from "components/ads/Toast"; import { Variant } from "components/ads/common"; @@ -73,6 +77,12 @@ import { EvaluationVersion } from "api/ApplicationApi"; import { makeUpdateJSCollection } from "sagas/JSPaneSagas"; import { ENTITY_TYPE } from "entities/AppsmithConsole"; import { Replayable } from "entities/Replay/ReplayEntity/ReplayEditor"; +import { + logActionExecutionError, + UncaughtPromiseError, +} from "sagas/ActionExecution/errorUtils"; +import { Channel } from "redux-saga"; +import { ActionDescription } from "entities/DataTree/actionTriggers"; let widgetTypeConfigMap: WidgetTypeConfigMap; @@ -165,23 +175,119 @@ export function* evaluateActionBindings( return values; } -export function* evaluateDynamicTrigger( +/* + * Used to evaluate and execute dynamic trigger end to end + * Widget action fields and JS Object run triggers this flow + * + * We start a duplex request with the worker and wait till the time we get a 'finished' event from the + * worker. Worker will evaluate a block of code and ask the main thread to execute it. The result of this + * execution is returned to the worker where it can resolve/reject the current promise. + */ +export function* evaluateAndExecuteDynamicTrigger( dynamicTrigger: string, + eventType: EventType, + triggerMeta: TriggerMeta, callbackData?: Array, ) { const unEvalTree = yield select(getUnevaluatedDataTree); + log.debug({ execute: dynamicTrigger }); - const workerResponse = yield call( - worker.request, + const { requestChannel, responseChannel } = yield call( + worker.duplexRequest, EVAL_WORKER_ACTIONS.EVAL_TRIGGER, { dataTree: unEvalTree, dynamicTrigger, callbackData }, ); + let keepAlive = true; - const { errors, triggers } = workerResponse; + while (keepAlive) { + const { requestData } = yield take(requestChannel); + log.debug({ requestData }); + if (requestData.finished) { + keepAlive = false; + /* Handle errors during evaluation + * A finish event with errors means that the error was not caught by the user code. + * We raise an error telling the user that an uncaught error has occured + * */ + if (requestData.result.errors.length) { + throw new UncaughtPromiseError( + requestData.result.errors[0].errorMessage, + ); + } + // It is possible to get a few triggers here if the user + // still uses the old way of action runs and not promises. For that we + // need to manually execute these triggers outside the promise flow + const { triggers } = requestData.result; + if (triggers && triggers.length) { + log.debug({ triggers }); + yield all( + triggers.map((trigger: ActionDescription) => + call(executeActionTriggers, trigger, eventType, triggerMeta), + ), + ); + } + // Return value of a promise is returned + return requestData.result; + } + yield call(evalErrorHandler, requestData.errors); + if (requestData.trigger) { + // if we have found a trigger, we need to execute it and respond back + log.debug({ trigger: requestData.trigger }); + yield spawn( + executeTriggerRequestSaga, + requestData, + eventType, + responseChannel, + triggerMeta, + ); + } + } +} - yield call(evalErrorHandler, errors); +interface ResponsePayload { + data: { + subRequestId: string; + reason?: string; + resolve?: unknown; + }; + success: boolean; +} - return triggers; +/* + * It is necessary to respond back as the worker is waiting with a pending promise and wanting to know if it should + * resolve or reject it with the data the execution has provided + */ +function* executeTriggerRequestSaga( + requestData: { trigger: ActionDescription; subRequestId: string }, + eventType: EventType, + responseChannel: Channel, + triggerMeta: TriggerMeta, +) { + const responsePayload: ResponsePayload = { + data: { + resolve: undefined, + reason: undefined, + subRequestId: requestData.subRequestId, + }, + success: false, + }; + try { + responsePayload.data.resolve = yield call( + executeActionTriggers, + requestData.trigger, + eventType, + triggerMeta, + ); + responsePayload.success = true; + } catch (e) { + // When error occurs in execution of triggers, + // a success: false is sent to reject the promise + responsePayload.data.reason = e; + responsePayload.success = false; + } + responseChannel.put({ + method: EVAL_WORKER_ACTIONS.PROCESS_TRIGGER, + ...responsePayload, + }); } export function* clearEvalCache() { @@ -197,18 +303,32 @@ export function* clearEvalPropertyCache(propertyPath: string) { } export function* executeFunction(collectionName: string, action: JSAction) { - const unEvalTree = yield select(getUnevaluatedDataTree); - const dynamicTrigger = collectionName + "." + action.name + "()"; + const functionCall = `${collectionName}.${action.name}()`; + const { isAsync } = action.actionConfiguration; + let response; + if (isAsync) { + try { + response = yield call( + evaluateAndExecuteDynamicTrigger, + functionCall, + EventType.ON_JS_FUNCTION_EXECUTE, + {}, + ); + } catch (e) { + if (e instanceof UncaughtPromiseError) { + logActionExecutionError(e.message); + } + response = { errors: [e], result: undefined }; + } + } else { + response = yield call(worker.request, EVAL_WORKER_ACTIONS.EXECUTE_SYNC_JS, { + functionCall, + }); + } - const workerResponse = yield call( - worker.request, - EVAL_WORKER_ACTIONS.EVAL_TRIGGER, - { dataTree: unEvalTree, dynamicTrigger, fullPropertyPath: dynamicTrigger }, - ); - - const { errors, result, triggers } = workerResponse; + const { errors, result } = response; yield call(evalErrorHandler, errors); - return { triggers, result }; + return result; } /** diff --git a/app/client/src/sagas/JSPaneSagas.ts b/app/client/src/sagas/JSPaneSagas.ts index 0063d865d9..e25202d54c 100644 --- a/app/client/src/sagas/JSPaneSagas.ts +++ b/app/client/src/sagas/JSPaneSagas.ts @@ -58,10 +58,7 @@ import { ENTITY_TYPE, PLATFORM_ERROR } from "entities/AppsmithConsole"; import LOG_TYPE from "entities/AppsmithConsole/logtype"; import PageApi from "api/PageApi"; import { updateCanvasWithDSL } from "sagas/PageSagas"; -import { ActionDescription } from "entities/DataTree/actionTriggers"; -import { executeActionTriggers } from "sagas/ActionExecution/ActionExecutionSagas"; export const JS_PLUGIN_PACKAGE_NAME = "js-plugin"; -import { EventType } from "constants/AppsmithActionConstants/ActionConstants"; import { updateReplayEntity } from "actions/pageActions"; function* handleCreateNewJsActionSaga(action: ReduxAction<{ pageId: string }>) { @@ -293,23 +290,7 @@ function* handleExecuteJSFunctionSaga( const { action, collectionId, collectionName } = data.payload; const actionId = action.id; try { - const { result, triggers } = yield call( - executeFunction, - collectionName, - action, - ); - if (triggers && triggers.length) { - yield all( - triggers.map((trigger: ActionDescription) => - call(executeActionTriggers, trigger, EventType.ON_CLICK, { - source: { - id: action.collectionId || "", - name: data.payload.collectionName, - }, - }), - ), - ); - } + const result = yield call(executeFunction, collectionName, action); yield put({ type: ReduxActionTypes.EXECUTE_JS_FUNCTION_SUCCESS, diff --git a/app/client/src/sagas/PostEvaluationSagas.ts b/app/client/src/sagas/PostEvaluationSagas.ts index 6df0d0c209..c089e82c95 100644 --- a/app/client/src/sagas/PostEvaluationSagas.ts +++ b/app/client/src/sagas/PostEvaluationSagas.ts @@ -31,7 +31,6 @@ import AnalyticsUtil from "../utils/AnalyticsUtil"; import { createMessage, ERROR_EVAL_ERROR_GENERIC, - ERROR_EVAL_TRIGGER, JS_OBJECT_BODY_INVALID, VALUE_IS_INVALID, } from "constants/messages"; @@ -41,7 +40,6 @@ import { getAppMode } from "selectors/applicationSelectors"; import { APP_MODE } from "entities/App"; import { dataTreeTypeDefCreator } from "utils/autocomplete/dataTreeTypeDefCreator"; import TernServer from "utils/autocomplete/TernServer"; -import { TriggerEvaluationError } from "sagas/ActionExecution/errorUtils"; const getDebuggerErrors = (state: AppState) => state.ui.debugger.errors; /** @@ -260,12 +258,6 @@ export function* evalErrorHandler( Sentry.captureException(error); break; } - case EvalErrorTypes.EVAL_TRIGGER_ERROR: { - log.error(error); - throw new TriggerEvaluationError( - createMessage(ERROR_EVAL_TRIGGER, error.message), - ); - } case EvalErrorTypes.EVAL_PROPERTY_ERROR: { log.debug(error); break; diff --git a/app/client/src/utils/DynamicBindingUtils.ts b/app/client/src/utils/DynamicBindingUtils.ts index 6351527f4f..9d7dda3265 100644 --- a/app/client/src/utils/DynamicBindingUtils.ts +++ b/app/client/src/utils/DynamicBindingUtils.ts @@ -115,7 +115,6 @@ export enum EvalErrorTypes { EVAL_TREE_ERROR = "EVAL_TREE_ERROR", UNKNOWN_ERROR = "UNKNOWN_ERROR", BAD_UNEVAL_TREE_ERROR = "BAD_UNEVAL_TREE_ERROR", - EVAL_TRIGGER_ERROR = "EVAL_TRIGGER_ERROR", PARSE_JS_ERROR = "PARSE_JS_ERROR", CLONE_ERROR = "CLONE_ERROR", EXTRACT_DEPENDENCY_ERROR = "EXTRACT_DEPENDENCY_ERROR", @@ -132,17 +131,17 @@ export enum EVAL_WORKER_ACTIONS { EVAL_TREE = "EVAL_TREE", EVAL_ACTION_BINDINGS = "EVAL_ACTION_BINDINGS", EVAL_TRIGGER = "EVAL_TRIGGER", + PROCESS_TRIGGER = "PROCESS_TRIGGER", CLEAR_PROPERTY_CACHE = "CLEAR_PROPERTY_CACHE", CLEAR_PROPERTY_CACHE_OF_WIDGET = "CLEAR_PROPERTY_CACHE_OF_WIDGET", CLEAR_CACHE = "CLEAR_CACHE", VALIDATE_PROPERTY = "VALIDATE_PROPERTY", UNDO = "undo", REDO = "redo", - PARSE_JS_FUNCTION_BODY = "PARSE_JS_FUNCTION_BODY", - EVAL_JS_FUNCTION = "EVAL_JS_FUNCTION", EVAL_EXPRESSION = "EVAL_EXPRESSION", UPDATE_REPLAY_OBJECT = "UPDATE_REPLAY_OBJECT", SET_EVALUATION_VERSION = "SET_EVALUATION_VERSION", + EXECUTE_SYNC_JS = "EXECUTE_SYNC_JS", } export type ExtraLibrary = { diff --git a/app/client/src/utils/JSPaneUtils.tsx b/app/client/src/utils/JSPaneUtils.tsx index 2cd5e2ef86..43abf1d53c 100644 --- a/app/client/src/utils/JSPaneUtils.tsx +++ b/app/client/src/utils/JSPaneUtils.tsx @@ -8,6 +8,7 @@ export type ParsedJSSubAction = { name: string; body: string; arguments: Array; + isAsync: boolean; }; export type ParsedBody = { @@ -43,6 +44,7 @@ export const getDifferenceInJSCollection = ( ...preExisted.actionConfiguration, body: action.body, jsArguments: action.arguments, + isAsync: action.isAsync, }, }); } diff --git a/app/client/src/utils/WorkerUtil.test.ts b/app/client/src/utils/WorkerUtil.test.ts index e031cd1f06..bf568b6afd 100644 --- a/app/client/src/utils/WorkerUtil.test.ts +++ b/app/client/src/utils/WorkerUtil.test.ts @@ -1,5 +1,5 @@ import { GracefulWorkerService } from "./WorkerUtil"; -import { runSaga } from "redux-saga"; +import { channel, runSaga } from "redux-saga"; import WebpackWorker from "worker-loader!"; const MessageType = "message"; @@ -209,4 +209,125 @@ describe("GracefulWorkerService", () => { await shutdown.toPromise(); expect(await task.toPromise()).not.toEqual(message); }); + + test("duplex request starter", async () => { + const w = new GracefulWorkerService(MockWorker); + await runSaga({}, w.start); + // Need this to work with eslint + if (MockWorker.instance === undefined) { + expect(MockWorker.instance).toBeDefined(); + return; + } + const requestData = { message: "Hello" }; + const method = "duplex_test"; + MockWorker.instance.postMessage = jest.fn(); + const duplexRequest = await runSaga( + {}, + w.duplexRequest, + method, + requestData, + ); + const handlers = await duplexRequest.toPromise(); + expect(handlers).toHaveProperty("requestChannel"); + expect(handlers).toHaveProperty("responseChannel"); + expect(MockWorker.instance.postMessage).toBeCalledWith({ + method, + requestData, + requestId: expect.stringContaining(method), + }); + }); + + test("duplex request channel handler", async () => { + const w = new GracefulWorkerService(MockWorker); + await runSaga({}, w.start); + const mockChannel = (name = "mock") => ({ + name, + take: jest.fn(), + put: jest.fn(), + flush: jest.fn(), + close: jest.fn(), + }); + const workerChannel = channel(); + const mockRequestChannel = mockChannel("request"); + const mockResponseChannel = mockChannel("response"); + runSaga( + {}, + w.duplexRequestHandler, + workerChannel, + mockRequestChannel, + mockResponseChannel, + ); + + let randomRequestCount = Math.floor(Math.random() * 10); + + for (randomRequestCount; randomRequestCount > 0; randomRequestCount--) { + workerChannel.put({ + responseData: { + test: randomRequestCount, + }, + }); + expect(mockRequestChannel.put).toBeCalledWith({ + requestData: { + test: randomRequestCount, + }, + }); + } + + workerChannel.put({ + responseData: { + finished: true, + }, + }); + + expect(mockResponseChannel.put).toBeCalledWith({ finished: true }); + + expect(mockRequestChannel.close).toBeCalled(); + }); + + test("duplex response channel handler", async () => { + const w = new GracefulWorkerService(MockWorker); + await runSaga({}, w.start); + + // Need this to work with eslint + if (MockWorker.instance === undefined) { + expect(MockWorker.instance).toBeDefined(); + return; + } + const mockChannel = (name = "mock") => ({ + name, + take: jest.fn(), + put: jest.fn(), + flush: jest.fn(), + close: jest.fn(), + }); + const mockWorkerChannel = mockChannel("worker"); + const responseChannel = channel(); + const workerRequestId = "testID"; + runSaga( + {}, + w.duplexResponseHandler, + workerRequestId, + mockWorkerChannel, + responseChannel, + ); + MockWorker.instance.postMessage = jest.fn(); + + let randomRequestCount = Math.floor(Math.random() * 10); + + for (randomRequestCount; randomRequestCount > 0; randomRequestCount--) { + responseChannel.put({ + test: randomRequestCount, + }); + expect(MockWorker.instance.postMessage).toBeCalledWith({ + test: randomRequestCount, + requestId: workerRequestId, + }); + } + + responseChannel.put({ + finished: true, + }); + + expect(mockWorkerChannel.close).toBeCalled(); + }); }); diff --git a/app/client/src/utils/WorkerUtil.ts b/app/client/src/utils/WorkerUtil.ts index 0e1b32469d..20dc6d0767 100644 --- a/app/client/src/utils/WorkerUtil.ts +++ b/app/client/src/utils/WorkerUtil.ts @@ -1,4 +1,4 @@ -import { cancelled, delay, put, take } from "redux-saga/effects"; +import { cancelled, delay, put, spawn, take } from "redux-saga/effects"; import { channel, Channel, buffers } from "redux-saga"; import _ from "lodash"; import log from "loglevel"; @@ -55,6 +55,9 @@ export class GracefulWorkerService { this.start = this.start.bind(this); this.request = this.request.bind(this); this._broker = this._broker.bind(this); + this.duplexRequest = this.duplexRequest.bind(this); + this.duplexRequestHandler = this.duplexRequestHandler.bind(this); + this.duplexResponseHandler = this.duplexResponseHandler.bind(this); // Do not buffer messages on this channel this._readyChan = channel(buffers.none()); @@ -159,11 +162,126 @@ export class GracefulWorkerService { log.debug(`Transfer ${method} took ${transferTime.toFixed(2)}ms`); } // Cleanup - yield ch.close(); + ch.close(); this._channels.delete(requestId); } } + /** + * When there needs to be a back and forth between both the threads, + * you can use duplex request to avoid closing a channel + * */ + *duplexRequest(method: string, requestData = {}): any { + yield this.ready(false); + // Impossible case, but helps avoid `?` later in code and makes it clearer. + if (!this._evaluationWorker) return; + + /** + * We create a unique channel to wait for a response of this specific request. + */ + const workerRequestId = `${method}__${_.uniqueId()}`; + // The worker channel is the main channel + // where the web worker messages will get posted + const workerChannel = channel(); + this._channels.set(workerRequestId, workerChannel); + // The main thread will listen to the + // request channel where it will get worker messages + const mainThreadRequestChannel = channel(); + // The main thread will respond back on the + // response channel which will be relayed to the worker + const mainThreadResponseChannel = channel(); + + // We spawn both the main thread request and response handler + yield spawn( + this.duplexRequestHandler, + workerChannel, + mainThreadRequestChannel, + mainThreadResponseChannel, + ); + yield spawn( + this.duplexResponseHandler, + workerRequestId, + workerChannel, + mainThreadResponseChannel, + ); + + // And post the first message to the worker + this._evaluationWorker.postMessage({ + method, + requestData, + requestId: workerRequestId, + }); + + // Returning these channels to the main thread so that they can listen and post on it + return { + responseChannel: mainThreadResponseChannel, + requestChannel: mainThreadRequestChannel, + }; + } + + *duplexRequestHandler( + workerChannel: Channel, + requestChannel: Channel, + responseChannel: Channel, + ) { + if (!this._evaluationWorker) return; + try { + let keepAlive = true; + while (keepAlive) { + // Wait for a message from the worker + const workerResponse = yield take(workerChannel); + const { responseData } = workerResponse; + // post that message to the request channel so the main thread can read it + requestChannel.put({ requestData: responseData }); + // If we get a finished flag, the worker is requesting to end the request + if (responseData.finished) { + keepAlive = false; + // Relay the finished flag to the response channel as well + responseChannel.put({ + finished: true, + }); + } + } + } catch (e) { + log.error(e); + } finally { + // Cleanup + requestChannel.close(); + } + } + + *duplexResponseHandler( + workerRequestId: string, + workerChannel: Channel, + responseChannel: Channel, + ) { + if (!this._evaluationWorker) return; + try { + let keepAlive = true; + while (keepAlive) { + // Wait for the main thread to respond back after a request + const response = yield take(responseChannel); + // If we get a finished flag, the worker is requesting to end the request + if (response.finished) { + keepAlive = false; + continue; + } + // send response to worker + this._evaluationWorker.postMessage({ + ...response, + requestId: workerRequestId, + }); + } + } catch (e) { + log.error(e); + } finally { + // clean up everything + responseChannel.close(); + workerChannel.close(); + this._channels.delete(workerRequestId); + } + } + private _broker(event: MessageEvent) { if (!event || !event.data) { return; diff --git a/app/client/src/utils/autocomplete/EntityDefinitions.ts b/app/client/src/utils/autocomplete/EntityDefinitions.ts index ac62391d71..ed6307a4d4 100644 --- a/app/client/src/utils/autocomplete/EntityDefinitions.ts +++ b/app/client/src/utils/autocomplete/EntityDefinitions.ts @@ -58,8 +58,9 @@ export const entityDefinitions: Record = { "!doc": "The response meta of the action", "!type": "?", }, - run: "fn(onSuccess: fn() -> void, onError: fn() -> void) -> void", - clear: "fn() -> void", + run: + "fn(onSuccess: fn() -> void, onError: fn() -> void) -> +Promise[:t=[!0..:t]]", + clear: "fn() -> +Promise[:t=[!0..:t]]", }; }, AUDIO_WIDGET: { @@ -469,35 +470,38 @@ export const GLOBAL_FUNCTIONS = { "!name": "DATA_TREE.APPSMITH.FUNCTIONS", navigateTo: { "!doc": "Action to navigate the user to another page or url", - "!type": "fn(pageNameOrUrl: string, params: {}, target?: string) -> void", + "!type": + "fn(pageNameOrUrl: string, params: {}, target?: string) -> +Promise[:t=[!0..:t]]", }, showAlert: { "!doc": "Show a temporary notification style message to the user", - "!type": "fn(message: string, style: string) -> void", + "!type": "fn(message: string, style: string) -> +Promise[:t=[!0..:t]]", }, showModal: { "!doc": "Open a modal", - "!type": "fn(modalName: string) -> void", + "!type": "fn(modalName: string) -> +Promise[:t=[!0..:t]]", }, closeModal: { "!doc": "Close a modal", - "!type": "fn(modalName: string) -> void", + "!type": "fn(modalName: string) -> +Promise[:t=[!0..:t]]", }, storeValue: { "!doc": "Store key value data locally", - "!type": "fn(key: string, value: any) -> void", + "!type": "fn(key: string, value: any) -> +Promise[:t=[!0..:t]]", }, download: { "!doc": "Download anything as a file", - "!type": "fn(data: any, fileName: string, fileType?: string) -> void", + "!type": + "fn(data: any, fileName: string, fileType?: string) -> +Promise[:t=[!0..:t]]", }, copyToClipboard: { "!doc": "Copy text to clipboard", - "!type": "fn(data: string, options: object) -> void", + "!type": "fn(data: string, options: object) -> +Promise[:t=[!0..:t]]", }, resetWidget: { "!doc": "Reset widget values", - "!type": "fn(widgetName: string, resetChildren: boolean) -> void", + "!type": + "fn(widgetName: string, resetChildren: boolean) -> +Promise[:t=[!0..:t]]", }, setInterval: { "!doc": "Execute triggers at a given interval", diff --git a/app/client/src/utils/autocomplete/TernServer.ts b/app/client/src/utils/autocomplete/TernServer.ts index 205c4278fa..994f6c6718 100644 --- a/app/client/src/utils/autocomplete/TernServer.ts +++ b/app/client/src/utils/autocomplete/TernServer.ts @@ -23,10 +23,10 @@ import SortRules from "./dataTypeSortRules"; import _ from "lodash"; const DEFS: Def[] = [ - GLOBAL_FUNCTIONS, - GLOBAL_DEFS, // @ts-ignore ecma, + GLOBAL_FUNCTIONS, + GLOBAL_DEFS, lodash, base64, moment, @@ -151,7 +151,7 @@ class TernServer { ) { this.server.deleteDefs(name); // @ts-ignore: No types available - this.server.addDefs(def, true); + this.server.addDefs(def); if (entityInfo) this.defEntityInformation = entityInfo; } diff --git a/app/client/src/workers/Actions.test.ts b/app/client/src/workers/Actions.test.ts index 2046de018e..90bd7a4e1d 100644 --- a/app/client/src/workers/Actions.test.ts +++ b/app/client/src/workers/Actions.test.ts @@ -1,297 +1,351 @@ import { DataTree, ENTITY_TYPE } from "entities/DataTree/dataTreeFactory"; import { PluginType } from "entities/Action"; -import { enhanceDataTreeWithFunctions } from "workers/Actions"; +import { createGlobalData } from "workers/evaluate"; describe("Add functions", () => { - it("adds functions correctly", () => { - const dataTree: DataTree = { - action1: { - actionId: "123", - data: {}, - config: {}, - pluginType: PluginType.API, - dynamicBindingPathList: [], - name: "action1", - bindingPaths: {}, - isLoading: false, - run: {}, - clear: {}, - responseMeta: { isExecutionSuccess: false }, - ENTITY_TYPE: ENTITY_TYPE.ACTION, - dependencyMap: {}, - logBlackList: {}, - }, - }; - const dataTreeWithFunctions = enhanceDataTreeWithFunctions(dataTree); - expect(window.actionPaths).toStrictEqual([ - "navigateTo", - "showAlert", - "showModal", - "closeModal", - "storeValue", - "download", - "copyToClipboard", - "resetWidget", - "action1.run", - "action1.clear", - "setInterval", - "clearInterval", - ]); + const workerEventMock = jest.fn(); + self.postMessage = workerEventMock; + self.ALLOW_ASYNC = true; + const dataTree: DataTree = { + action1: { + actionId: "123", + data: {}, + config: {}, + pluginType: PluginType.API, + dynamicBindingPathList: [], + name: "action1", + bindingPaths: {}, + isLoading: false, + run: {}, + clear: {}, + responseMeta: { isExecutionSuccess: false }, + ENTITY_TYPE: ENTITY_TYPE.ACTION, + dependencyMap: {}, + logBlackList: {}, + }, + }; + self.TRIGGER_COLLECTOR = []; + const dataTreeWithFunctions = createGlobalData( + dataTree, + {}, + { requestId: "EVAL_TRIGGER" }, + ); + beforeEach(() => { + workerEventMock.mockReset(); + self.postMessage = workerEventMock; + }); + + it("action.run works", () => { // Action run const onSuccess = () => "success"; const onError = () => "failure"; const actionParams = { param1: "value1" }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const actionRunResponse = dataTreeWithFunctions.action1.run( - onSuccess, - onError, - actionParams, - ); - expect(actionRunResponse.action).toStrictEqual({ - type: "PROMISE", - payload: { - executor: [ - { - type: "RUN_PLUGIN_ACTION", - payload: { - actionId: "123", - params: { param1: "value1" }, - }, - }, - ], - then: [`{{ function () { return "success"; } }}`], - catch: `{{ function () { return "failure"; } }}`, + + workerEventMock.mockReturnValue({ + data: { + method: "PROCESS_TRIGGER", + requestId: "EVAL_TRIGGER", + success: true, + data: { + a: "b", + }, }, }); - // New syntax for action run with params passed as first argument - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const actionNewRunResponse = dataTreeWithFunctions.action1.run( - actionParams, - ); - expect(actionNewRunResponse.action).toStrictEqual({ - type: "PROMISE", + // Old syntax works + expect( + dataTreeWithFunctions.action1.run(onSuccess, onError, actionParams), + ).toBe(undefined); + expect(self.TRIGGER_COLLECTOR).toHaveLength(1); + expect(self.TRIGGER_COLLECTOR[0]).toStrictEqual({ payload: { - executor: [ - { - type: "RUN_PLUGIN_ACTION", - payload: { - actionId: "123", - params: { param1: "value1" }, - }, - }, - ], - then: [], + actionId: "123", + onError: 'function () { return "failure"; }', + onSuccess: 'function () { return "success"; }', + params: { + param1: "value1", + }, }, + type: "RUN_PLUGIN_ACTION", }); - // Action clear - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const actionClearResponse = dataTreeWithFunctions.action1.clear(); - - expect(actionClearResponse.action).toStrictEqual({ - type: "PROMISE", - payload: { - executor: [ - { - type: "CLEAR_PLUGIN_ACTION", - payload: { - actionId: "123", - }, + // new syntax works + expect( + dataTreeWithFunctions.action1 + .run(actionParams) + .then(onSuccess) + .catch(onError), + ).resolves.toBe({ a: "b" }); + expect(workerEventMock).lastCalledWith({ + type: "PROCESS_TRIGGER", + requestId: "EVAL_TRIGGER", + responseData: { + errors: [], + subRequestId: expect.stringContaining("EVAL_TRIGGER_"), + trigger: { + type: "RUN_PLUGIN_ACTION", + payload: { + actionId: "123", + params: { param1: "value1" }, }, - ], - then: [], + }, }, }); + // New syntax without params + expect(dataTreeWithFunctions.action1.run()).resolves.toBe({ a: "b" }); - // Navigate To - const pageNameOrUrl = "www.google.com"; - const params = "{ param1: value1 }"; - const target = "NEW_WINDOW"; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const navigateToResponse = dataTreeWithFunctions.navigateTo( - pageNameOrUrl, - params, - target, - ); - expect(navigateToResponse.action).toStrictEqual({ - type: "PROMISE", - payload: { - executor: [ - { - type: "NAVIGATE_TO", - payload: { - pageNameOrUrl, - params, - target, - }, + expect(workerEventMock).lastCalledWith({ + type: "PROCESS_TRIGGER", + requestId: "EVAL_TRIGGER", + responseData: { + errors: [], + subRequestId: expect.stringContaining("EVAL_TRIGGER_"), + trigger: { + type: "RUN_PLUGIN_ACTION", + payload: { + actionId: "123", + params: {}, }, - ], - then: [], - }, - }); - - // Show alert - const message = "Alert message"; - const style = "info"; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const showAlertResponse = dataTreeWithFunctions.showAlert(message, style); - expect(showAlertResponse.action).toStrictEqual({ - type: "PROMISE", - payload: { - executor: [ - { - type: "SHOW_ALERT", - payload: { - message, - style, - }, - }, - ], - then: [], - }, - }); - - // Show Modal - const modalName = "Modal 1"; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const showModalResponse = dataTreeWithFunctions.showModal(modalName); - expect(showModalResponse.action).toStrictEqual({ - type: "PROMISE", - payload: { - executor: [ - { - type: "SHOW_MODAL_BY_NAME", - payload: { - modalName, - }, - }, - ], - then: [], - }, - }); - - // Close Modal - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const closeModalResponse = dataTreeWithFunctions.closeModal(modalName); - expect(closeModalResponse.action).toStrictEqual({ - type: "PROMISE", - payload: { - executor: [ - { - type: "CLOSE_MODAL", - payload: { - modalName, - }, - }, - ], - then: [], - }, - }); - - // Store value - const key = "some"; - const value = "thing"; - const persist = false; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const storeValueResponse = dataTreeWithFunctions.storeValue( - key, - value, - persist, - ); - expect(storeValueResponse.action).toStrictEqual({ - type: "PROMISE", - payload: { - executor: [ - { - type: "STORE_VALUE", - payload: { - key, - value, - persist, - }, - }, - ], - then: [], - }, - }); - - // Download - const data = "file"; - const name = "downloadedFile.txt"; - const type = "text"; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const downloadResponse = dataTreeWithFunctions.download(data, name, type); - expect(downloadResponse.action).toStrictEqual({ - type: "PROMISE", - payload: { - executor: [ - { - type: "DOWNLOAD", - payload: { - data, - name, - type, - }, - }, - ], - then: [], - }, - }); - - // copy to clipboard - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const copyToClipboardResponse = dataTreeWithFunctions.copyToClipboard(data); - expect(copyToClipboardResponse.action).toStrictEqual({ - type: "PROMISE", - payload: { - executor: [ - { - type: "COPY_TO_CLIPBOARD", - payload: { - data, - options: { debug: undefined, format: undefined }, - }, - }, - ], - then: [], - }, - }); - - // reset widget - const widgetName = "widget1"; - const resetChildren = true; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const resetWidgetResponse = dataTreeWithFunctions.resetWidget( - widgetName, - resetChildren, - ); - expect(resetWidgetResponse.action).toStrictEqual({ - type: "PROMISE", - payload: { - executor: [ - { - type: "RESET_WIDGET_META_RECURSIVE_BY_NAME", - payload: { - widgetName, - resetChildren, - }, - }, - ], - then: [], + }, }, }); }); + + it("action.clear works", () => { + expect(dataTreeWithFunctions.action1.clear()).resolves.toBe({}); + expect(workerEventMock).lastCalledWith({ + type: "PROCESS_TRIGGER", + requestId: "EVAL_TRIGGER", + responseData: { + errors: [], + subRequestId: expect.stringContaining("EVAL_TRIGGER_"), + trigger: { + type: "CLEAR_PLUGIN_ACTION", + payload: { + actionId: "123", + }, + }, + }, + }); + }); + + it("navigateTo works", () => { + const pageNameOrUrl = "www.google.com"; + const params = "{ param1: value1 }"; + const target = "NEW_WINDOW"; + + expect( + dataTreeWithFunctions.navigateTo(pageNameOrUrl, params, target), + ).resolves.toBe({}); + + expect(workerEventMock).lastCalledWith({ + type: "PROCESS_TRIGGER", + requestId: "EVAL_TRIGGER", + responseData: { + errors: [], + subRequestId: expect.stringContaining("EVAL_TRIGGER_"), + trigger: { + type: "NAVIGATE_TO", + payload: { + pageNameOrUrl, + params, + target, + }, + }, + }, + }); + }); + + it("showAlert works", () => { + const message = "Alert message"; + const style = "info"; + expect(dataTreeWithFunctions.showAlert(message, style)).resolves.toBe({}); + expect(workerEventMock).lastCalledWith({ + type: "PROCESS_TRIGGER", + requestId: "EVAL_TRIGGER", + responseData: { + errors: [], + subRequestId: expect.stringContaining("EVAL_TRIGGER_"), + trigger: { + type: "SHOW_ALERT", + payload: { + message, + style, + }, + }, + }, + }); + }); + + it("showModal works", () => { + const modalName = "Modal 1"; + + expect(dataTreeWithFunctions.showModal(modalName)).resolves.toBe({}); + expect(workerEventMock).lastCalledWith({ + type: "PROCESS_TRIGGER", + requestId: "EVAL_TRIGGER", + responseData: { + errors: [], + subRequestId: expect.stringContaining("EVAL_TRIGGER_"), + trigger: { + type: "SHOW_MODAL_BY_NAME", + payload: { + modalName, + }, + }, + }, + }); + }); + + it("closeModal works", () => { + const modalName = "Modal 1"; + expect(dataTreeWithFunctions.closeModal(modalName)).resolves.toBe({}); + expect(workerEventMock).lastCalledWith({ + type: "PROCESS_TRIGGER", + requestId: "EVAL_TRIGGER", + responseData: { + errors: [], + subRequestId: expect.stringContaining("EVAL_TRIGGER_"), + trigger: { + type: "CLOSE_MODAL", + payload: { + modalName, + }, + }, + }, + }); + }); + + it("storeValue works", () => { + const key = "some"; + const value = "thing"; + const persist = false; + + expect(dataTreeWithFunctions.storeValue(key, value, persist)).resolves.toBe( + {}, + ); + expect(workerEventMock).lastCalledWith({ + type: "PROCESS_TRIGGER", + requestId: "EVAL_TRIGGER", + responseData: { + errors: [], + subRequestId: expect.stringContaining("EVAL_TRIGGER_"), + trigger: { + type: "STORE_VALUE", + payload: { + key, + value, + persist, + }, + }, + }, + }); + }); + + it("download works", () => { + const data = "file"; + const name = "downloadedFile.txt"; + const type = "text"; + + expect(dataTreeWithFunctions.download(data, name, type)).resolves.toBe({}); + expect(workerEventMock).lastCalledWith({ + type: "PROCESS_TRIGGER", + requestId: "EVAL_TRIGGER", + responseData: { + errors: [], + subRequestId: expect.stringContaining("EVAL_TRIGGER_"), + trigger: { + type: "DOWNLOAD", + payload: { + data, + name, + type, + }, + }, + }, + }); + }); + + it("copyToClipboard works", () => { + const data = "file"; + expect(dataTreeWithFunctions.copyToClipboard(data)).resolves.toBe({}); + expect(workerEventMock).lastCalledWith({ + type: "PROCESS_TRIGGER", + requestId: "EVAL_TRIGGER", + responseData: { + errors: [], + subRequestId: expect.stringContaining("EVAL_TRIGGER_"), + trigger: { + type: "COPY_TO_CLIPBOARD", + payload: { + data, + options: { debug: undefined, format: undefined }, + }, + }, + }, + }); + }); + + it("resetWidget works", () => { + const widgetName = "widget1"; + const resetChildren = true; + + expect( + dataTreeWithFunctions.resetWidget(widgetName, resetChildren), + ).resolves.toBe({}); + expect(workerEventMock).lastCalledWith({ + type: "PROCESS_TRIGGER", + requestId: "EVAL_TRIGGER", + responseData: { + errors: [], + subRequestId: expect.stringContaining("EVAL_TRIGGER_"), + trigger: { + type: "RESET_WIDGET_META_RECURSIVE_BY_NAME", + payload: { + widgetName, + resetChildren, + }, + }, + }, + }); + }); + + it("setInterval works", () => { + const callback = () => "test"; + const interval = 5000; + const id = "myInterval"; + + expect(dataTreeWithFunctions.setInterval(callback, interval, id)).toBe( + undefined, + ); + expect(self.TRIGGER_COLLECTOR).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + payload: { + callback: 'function () { return "test"; }', + id: "myInterval", + interval: 5000, + }, + type: "SET_INTERVAL", + }), + ]), + ); + }); + + it("clearInterval works", () => { + const id = "myInterval"; + + expect(dataTreeWithFunctions.clearInterval(id)).toBe(undefined); + expect(self.TRIGGER_COLLECTOR).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + payload: { + id: "myInterval", + }, + type: "CLEAR_INTERVAL", + }), + ]), + ); + }); }); diff --git a/app/client/src/workers/Actions.ts b/app/client/src/workers/Actions.ts index 2afa611085..b4500d02ab 100644 --- a/app/client/src/workers/Actions.ts +++ b/app/client/src/workers/Actions.ts @@ -1,183 +1,41 @@ /* eslint-disable @typescript-eslint/ban-types */ -/* - * An AppsmithPromise is a mock promise class to replicate promise functionalities - * in the Appsmith world. - * - * To mimic the async nature of promises, we will return back - * action descriptors that get resolved in the main thread and then go back to action - * execution flow as the workflow is designed. - * - * Whenever an async call needs to run, it is wrapped around this promise descriptor - * and sent to the main thread to execute. - * - * new Promise(() => { - * return Api1.run() - * }) - * .then(() => { - * return Api2.run() - * }) - * .catch(() => { - * return showMessage('An Error Occurred', 'error') - * }) - * - * { - * type: "APPSMITH_PROMISE", - * payload: { - * executor: [{ - * type: "EXECUTE_ACTION", - * payload: { actionId: "..." } - * }] - * then: ["() => { return Api2.run() }"], - * catch: "() => { return showMessage('An Error Occurred', 'error) }" - * } - * } - * - * - * - * */ - -import { - ActionDispatcher, - DataTree, - DataTreeEntity, -} from "entities/DataTree/dataTreeFactory"; +import { DataTree, DataTreeEntity } from "entities/DataTree/dataTreeFactory"; import _ from "lodash"; -import { - getEntityNameAndPropertyPath, - isAction, - isAppsmithEntity, - isTrueObject, -} from "./evaluationUtils"; +import { isAction, isAppsmithEntity, isTrueObject } from "./evaluationUtils"; import { ActionDescription, ActionTriggerType, - PromiseActionDescription, } from "entities/DataTree/actionTriggers"; import { NavigationTargetType } from "sagas/ActionExecution/NavigateActionSaga"; +import { promisifyAction } from "workers/PromisifyAction"; -export type AppsmithPromisePayload = { - executor: ActionDescription[]; - then: string[]; - catch?: string; - finally?: string; -}; - -export const pusher: ActionDispatcher = function( - this: { triggers: ActionDescription[]; isPromise: boolean }, - action: any, - ...payload: any[] -) { - const actionPayload = action(...payload); - if (actionPayload instanceof AppsmithPromise) { - this.triggers.push(actionPayload.action); - return actionPayload; - } - return action; -}; - -export const pusherOverride = (revert = false) => { - self.actionPaths.forEach((actionPath) => { - const { entityName, propertyPath } = getEntityNameAndPropertyPath( - actionPath, - ); - const promiseThis = { triggers: promiseTriggers, isPromise: true }; - const globalThis = { triggers: self.triggers, isPromise: false }; - const overrideThis = revert ? globalThis : promiseThis; - if (entityName === actionPath) { - const action = _.get(DATA_TREE_FUNCTIONS, actionPath); - if (action) { - _.set(self, actionPath, pusher.bind(overrideThis, action)); - } - } else { - const entity = _.get(self, entityName); - const funcCreator = DATA_TREE_FUNCTIONS[propertyPath]; - if (typeof funcCreator === "object" && "qualifier" in funcCreator) { - const func = funcCreator.func(entity); - _.set(self, actionPath, pusher.bind(overrideThis, func)); - } - } - }); -}; - -let promiseTriggers: ActionDescription[] = []; - -export class AppsmithPromise { - action: PromiseActionDescription = { - type: ActionTriggerType.PROMISE, - payload: { - executor: [], - then: [], - }, - }; - triggerReference?: number; - - constructor(executor: ActionDescription[] | (() => ActionDescription[])) { - if (typeof executor === "function") { - pusherOverride(); - executor(); - this.action.payload.executor = [...promiseTriggers]; - promiseTriggers = []; - pusherOverride(true); - } else { - this.action.payload.executor = executor; - } - this._attachToSelfTriggers(); - return this; - } - - private removeDuplicates() { - self.triggers = self.triggers.filter((trigger) => { - return !this.action.payload.executor.includes(trigger); - }); - } - - private _attachToSelfTriggers() { - if (self.triggers) { - this.removeDuplicates(); - if (_.isNumber(this.triggerReference)) { - self.triggers[this.triggerReference] = this.action; - } else { - self.triggers.push(this.action); - this.triggerReference = self.triggers.length - 1; - } - } - } - - then(executor?: Function) { - if (executor) { - this.action.payload.then.push(`{{ ${executor.toString()} }}`); - this._attachToSelfTriggers(); - } - return this; - } - - catch(executor: Function) { - if (executor) { - this.action.payload.catch = `{{ ${executor.toString()} }}`; - this._attachToSelfTriggers(); - } - return this; - } - - finally(executor: Function) { - if (executor) { - this.action.payload.finally = `{{ ${executor.toString()} }}`; - this._attachToSelfTriggers(); - } - return this; - } - - static all(actions: ActionDescription[]) { - return new AppsmithPromise(actions); +declare global { + interface Window { + ALLOW_ASYNC?: boolean; + IS_ASYNC?: boolean; + TRIGGER_COLLECTOR: ActionDescription[]; } } +enum ExecutionType { + PROMISE = "PROMISE", + TRIGGER = "TRIGGER", +} + +type ActionDescriptionWithExecutionType = ActionDescription & { + executionType: ExecutionType; +}; + +type ActionDispatcherWithExecutionType = ( + ...args: any[] +) => ActionDescriptionWithExecutionType; + const DATA_TREE_FUNCTIONS: Record< string, - | ActionDispatcher + | ActionDispatcherWithExecutionType | { qualifier: (entity: DataTreeEntity) => boolean; - func: (entity: DataTreeEntity) => ActionDispatcher; + func: (entity: DataTreeEntity) => ActionDispatcherWithExecutionType; path?: string; } > = { @@ -186,169 +44,174 @@ const DATA_TREE_FUNCTIONS: Record< params: Record, target?: NavigationTargetType, ) { - return new AppsmithPromise([ - { - type: ActionTriggerType.NAVIGATE_TO, - payload: { pageNameOrUrl, params, target }, - }, - ]); + return { + type: ActionTriggerType.NAVIGATE_TO, + payload: { pageNameOrUrl, params, target }, + executionType: ExecutionType.PROMISE, + }; }, showAlert: function( message: string, style: "info" | "success" | "warning" | "error" | "default", ) { - return new AppsmithPromise([ - { - type: ActionTriggerType.SHOW_ALERT, - payload: { message, style }, - }, - ]); + return { + type: ActionTriggerType.SHOW_ALERT, + payload: { message, style }, + executionType: ExecutionType.PROMISE, + }; }, showModal: function(modalName: string) { - return new AppsmithPromise([ - { - type: ActionTriggerType.SHOW_MODAL_BY_NAME, - payload: { modalName }, - }, - ]); + return { + type: ActionTriggerType.SHOW_MODAL_BY_NAME, + payload: { modalName }, + executionType: ExecutionType.PROMISE, + }; }, closeModal: function(modalName: string) { - return new AppsmithPromise([ - { - type: ActionTriggerType.CLOSE_MODAL, - payload: { modalName }, - }, - ]); + return { + type: ActionTriggerType.CLOSE_MODAL, + payload: { modalName }, + executionType: ExecutionType.PROMISE, + }; }, storeValue: function(key: string, value: string, persist = true) { // momentarily store this value in local state to support loops _.set(self, `appsmith.store[${key}]`, value); - return new AppsmithPromise([ - { - type: ActionTriggerType.STORE_VALUE, - payload: { key, value, persist }, - }, - ]); + return { + type: ActionTriggerType.STORE_VALUE, + payload: { key, value, persist }, + executionType: ExecutionType.PROMISE, + }; }, download: function(data: string, name: string, type: string) { - return new AppsmithPromise([ - { - type: ActionTriggerType.DOWNLOAD, - payload: { data, name, type }, - }, - ]); + return { + type: ActionTriggerType.DOWNLOAD, + payload: { data, name, type }, + executionType: ExecutionType.PROMISE, + }; }, copyToClipboard: function( data: string, options?: { debug?: boolean; format?: string }, ) { - return new AppsmithPromise([ - { - type: ActionTriggerType.COPY_TO_CLIPBOARD, - payload: { - data, - options: { debug: options?.debug, format: options?.format }, - }, + return { + type: ActionTriggerType.COPY_TO_CLIPBOARD, + payload: { + data, + options: { debug: options?.debug, format: options?.format }, }, - ]); + executionType: ExecutionType.PROMISE, + }; }, resetWidget: function(widgetName: string, resetChildren = true) { - return new AppsmithPromise([ - { - type: ActionTriggerType.RESET_WIDGET_META_RECURSIVE_BY_NAME, - payload: { widgetName, resetChildren }, - }, - ]); + return { + type: ActionTriggerType.RESET_WIDGET_META_RECURSIVE_BY_NAME, + payload: { widgetName, resetChildren }, + executionType: ExecutionType.PROMISE, + }; }, run: { qualifier: (entity) => isAction(entity), func: (entity) => + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore function( - onSuccessOrParams?: Function | Record, - onError?: Function, + onSuccessOrParams?: () => unknown | Record, + onError?: () => unknown, params = {}, - ) { - const isOldSignature = typeof onSuccessOrParams === "function"; - const isNewSignature = isTrueObject(onSuccessOrParams); - const runActionPromise = new AppsmithPromise([ - { + ): ActionDescriptionWithExecutionType { + const isOldSignature = + typeof onSuccessOrParams === "function" || + typeof onError === "function"; + + if (isOldSignature) { + // Backwards compatibility + return { type: ActionTriggerType.RUN_PLUGIN_ACTION, payload: { actionId: isAction(entity) ? entity.actionId : "", - params: isNewSignature ? onSuccessOrParams : params, + onSuccess: onSuccessOrParams + ? onSuccessOrParams.toString() + : undefined, + onError: onError ? onError.toString() : undefined, + params, }, - }, - ]); - if (isOldSignature && typeof onSuccessOrParams === "function") { - if (onSuccessOrParams) runActionPromise.then(onSuccessOrParams); - if (onError) runActionPromise.catch(onError); + executionType: ExecutionType.TRIGGER, + }; + } else { + return { + type: ActionTriggerType.RUN_PLUGIN_ACTION, + payload: { + actionId: isAction(entity) ? entity.actionId : "", + params: isTrueObject(onSuccessOrParams) ? onSuccessOrParams : {}, + }, + executionType: ExecutionType.PROMISE, + }; } - return runActionPromise; }, }, clear: { qualifier: (entity) => isAction(entity), - func: (entity) => () => { - return new AppsmithPromise([ - { + func: (entity) => + function() { + return { type: ActionTriggerType.CLEAR_PLUGIN_ACTION, payload: { actionId: isAction(entity) ? entity.actionId : "", }, - }, - ]); - }, + executionType: ExecutionType.PROMISE, + }; + }, }, setInterval: function(callback: Function, interval: number, id?: string) { - return new AppsmithPromise([ - { - type: ActionTriggerType.SET_INTERVAL, - payload: { - callback: callback.toString(), - interval, - id, - }, + return { + type: ActionTriggerType.SET_INTERVAL, + payload: { + callback: callback.toString(), + interval, + id, }, - ]); + executionType: ExecutionType.TRIGGER, + }; }, clearInterval: function(id: string) { - return new AppsmithPromise([ - { - type: ActionTriggerType.CLEAR_INTERVAL, - payload: { - id, - }, + return { + type: ActionTriggerType.CLEAR_INTERVAL, + payload: { + id, }, - ]); + executionType: ExecutionType.TRIGGER, + }; }, getGeoLocation: { qualifier: (entity) => isAppsmithEntity(entity), path: "appsmith.geolocation.getCurrentPosition", func: () => function( - successCallback?: Function, - errorCallback?: Function, + successCallback?: () => unknown, + errorCallback?: () => unknown, options?: { maximumAge?: number; timeout?: number; enableHighAccuracy?: boolean; }, ) { - const mainRequest = new AppsmithPromise([ - { - type: ActionTriggerType.GET_CURRENT_LOCATION, - payload: { - options, - }, + return { + type: ActionTriggerType.GET_CURRENT_LOCATION, + payload: { + options, + onError: errorCallback + ? `{{${errorCallback.toString()}}}` + : undefined, + onSuccess: successCallback + ? `{{${successCallback.toString()}}}` + : undefined, }, - ]); - if (errorCallback) { - mainRequest.catch(errorCallback); - } - if (successCallback) { - mainRequest.then(successCallback); - } - return mainRequest; + executionType: + errorCallback || successCallback + ? ExecutionType.TRIGGER + : ExecutionType.PROMISE, + }; }, }, watchGeoLocation: { @@ -364,20 +227,19 @@ const DATA_TREE_FUNCTIONS: Record< enableHighAccuracy?: boolean; }, ) { - return new AppsmithPromise([ - { - type: ActionTriggerType.WATCH_CURRENT_LOCATION, - payload: { - options, - onSuccess: onSuccessCallback - ? `{{${onSuccessCallback.toString()}}}` - : undefined, - onError: onErrorCallback - ? `{{${onErrorCallback.toString()}}}` - : undefined, - }, + return { + type: ActionTriggerType.WATCH_CURRENT_LOCATION, + payload: { + options, + onSuccess: onSuccessCallback + ? `{{${onSuccessCallback.toString()}}}` + : undefined, + onError: onErrorCallback + ? `{{${onErrorCallback.toString()}}}` + : undefined, }, - ]); + executionType: ExecutionType.TRIGGER, + }; }, }, stopWatchGeoLocation: { @@ -385,28 +247,21 @@ const DATA_TREE_FUNCTIONS: Record< path: "appsmith.geolocation.clearWatch", func: () => function() { - return new AppsmithPromise([ - { - type: ActionTriggerType.STOP_WATCHING_CURRENT_LOCATION, - }, - ]); + return { + type: ActionTriggerType.STOP_WATCHING_CURRENT_LOCATION, + payload: {}, + executionType: ExecutionType.PROMISE, + }; }, }, }; -declare global { - interface Window { - triggers: ActionDescription[]; - actionPaths: string[]; - } -} - export const enhanceDataTreeWithFunctions = ( dataTree: Readonly, + requestId = "", ): DataTree => { const withFunction: DataTree = _.cloneDeep(dataTree); - self.triggers = []; - self.actionPaths = []; + self.TRIGGER_COLLECTOR = []; Object.entries(DATA_TREE_FUNCTIONS).forEach(([name, funcOrFuncCreator]) => { if ( @@ -416,23 +271,68 @@ export const enhanceDataTreeWithFunctions = ( Object.entries(dataTree).forEach(([entityName, entity]) => { if (funcOrFuncCreator.qualifier(entity)) { const func = funcOrFuncCreator.func(entity); - const funcName = funcOrFuncCreator.path || `${entityName}.${name}`; + const funcName = `${funcOrFuncCreator.path || + `${entityName}.${name}`}`; _.set( withFunction, funcName, - pusher.bind({ triggers: self.triggers, isPromise: false }, func), + pusher.bind( + { + TRIGGER_COLLECTOR: self.TRIGGER_COLLECTOR, + REQUEST_ID: requestId, + }, + func, + ), ); - self.actionPaths.push(funcName); } }); } else { - withFunction[name] = pusher.bind( - { triggers: self.triggers, isPromise: false }, - funcOrFuncCreator, + _.set( + withFunction, + name, + pusher.bind( + { + TRIGGER_COLLECTOR: self.TRIGGER_COLLECTOR, + REQUEST_ID: requestId, + }, + funcOrFuncCreator, + ), ); - self.actionPaths.push(name); } }); return withFunction; }; + +/** + * The Pusher function is created to decide the proper execution method + * and payload of a platform action. It is bound to the platform functions and + * get a requestId and TriggerCollector array in its "this" context. + * Depending on the executionType of an action, it will add the action trigger description + * in the correct place. + * + * For old trigger based functions, it will add it to the trigger collector to be executed in parallel + * like the old way of action execution and end the evaluation. + * + * For new promise based functions, it will promisify the action so that it can wait for an execution + * before resolving and moving on with the promise workflow + * + * **/ +export const pusher = function( + this: { TRIGGER_COLLECTOR: ActionDescription[]; REQUEST_ID: string }, + action: ActionDispatcherWithExecutionType, + ...args: any[] +) { + const actionDescription = action(...args); + const { executionType, payload, type } = actionDescription; + const actionPayload = { + type, + payload, + } as ActionDescription; + + if (executionType && executionType === ExecutionType.TRIGGER) { + this.TRIGGER_COLLECTOR.push(actionPayload); + } else { + return promisifyAction(this.REQUEST_ID, actionPayload); + } +}; diff --git a/app/client/src/workers/DataTreeEvaluator.ts b/app/client/src/workers/DataTreeEvaluator.ts index 6f912c1b3f..0f8f181fa8 100644 --- a/app/client/src/workers/DataTreeEvaluator.ts +++ b/app/client/src/workers/DataTreeEvaluator.ts @@ -56,19 +56,21 @@ import { THIS_DOT_PARAMS_KEY, } from "constants/AppsmithActionConstants/ActionConstants"; import { DATA_BIND_REGEX } from "constants/BindingsConstants"; -import evaluate, { +import evaluateSync, { createGlobalData, EvalResult, EvaluateContext, EvaluationScriptType, getScriptToEval, + evaluateAsync, + isFunctionAsync, } from "workers/evaluate"; import { substituteDynamicBindingWithValues } from "workers/evaluationSubstitution"; import { Severity } from "entities/AppsmithConsole"; import { getLintingErrors } from "workers/lint"; -import { JSUpdate } from "utils/JSPaneUtils"; import { error as logError } from "loglevel"; import { extractIdentifiersFromCode } from "workers/ast"; +import { JSUpdate } from "utils/JSPaneUtils"; export default class DataTreeEvaluator { dependencyMap: DependencyMap = {}; @@ -233,7 +235,7 @@ export default class DataTreeEvaluator { translateDiffEventToDataTreeDiffEvent(diff, localUnEvalTree), ), ); - //save parsed functions in resolveJSfunctions, update current state of js collection + //save parsed functions in resolveJSFunctions, update current state of js collection const parsedCollections = this.parseJSActions( localUnEvalTree, jsTranslatedDiffs, @@ -598,7 +600,6 @@ export default class DataTreeEvaluator { currentTree, resolvedFunctions, evaluationSubstitutionType, - false, contextData, undefined, fullPropertyPath, @@ -753,23 +754,11 @@ export default class DataTreeEvaluator { }); } - clearAllCaches() { - this.parsedValueCache.clear(); - this.clearErrors(); - this.dependencyMap = {}; - this.allKeys = {}; - this.inverseDependencyMap = {}; - this.sortedDependencies = []; - this.evalTree = {}; - this.oldUnEvalTree = {}; - } - getDynamicValue( dynamicBinding: string, data: DataTree, resolvedFunctions: Record, evaluationSubstitutionType: EvaluationSubstitutionType, - returnTriggers: boolean, contextData?: EvaluateContext, callBackData?: Array, fullPropertyPath?: string, @@ -782,20 +771,11 @@ export default class DataTreeEvaluator { propertyPath = fullPropertyPath.split(".")[1]; entity = data[entityName]; } + // Get the {{binding}} bound values const { jsSnippets, stringSegments } = getDynamicBindings( dynamicBinding, entity, ); - if (returnTriggers) { - return this.evaluateDynamicBoundValue( - jsSnippets[0], - data, - resolvedFunctions, - contextData, - callBackData, - returnTriggers, - ); - } if (stringSegments.length) { // Get the Data Tree value of those "binding "paths const values = jsSnippets.map((jsSnippet, index) => { @@ -810,7 +790,6 @@ export default class DataTreeEvaluator { resolvedFunctions, contextData, callBackData, - entity && isJSAction(entity), ); if (fullPropertyPath && result.errors.length) { addErrorToEntityProperty(result.errors, data, fullPropertyPath); @@ -858,6 +837,24 @@ export default class DataTreeEvaluator { return undefined; } + async evaluateTriggers( + userScript: string, + dataTree: DataTree, + requestId: string, + resolvedFunctions: Record, + callbackData: Array, + ) { + const { jsSnippets } = getDynamicBindings(userScript); + return evaluateAsync( + jsSnippets[0] || userScript, + dataTree, + requestId, + resolvedFunctions, + {}, + callbackData, + ); + } + // Paths are expected to have "{name}.{path}" signature // Also returns any action triggers found after evaluating value evaluateDynamicBoundValue( @@ -866,21 +863,18 @@ export default class DataTreeEvaluator { resolvedFunctions: Record, contextData?: EvaluateContext, callbackData?: Array, - isTriggerBased = false, ): EvalResult { try { - return evaluate( + return evaluateSync( js, data, resolvedFunctions, contextData, callbackData, - isTriggerBased, ); } catch (e) { return { result: undefined, - triggers: [], errors: [ { errorType: PropertyEvaluationErrorType.PARSE, @@ -903,25 +897,15 @@ export default class DataTreeEvaluator { isDefaultProperty: boolean, ): any { const { propertyPath } = getEntityNameAndPropertyPath(fullPropertyPath); - let valueToValidate = evalPropertyValue; if (isPathADynamicTrigger(widget, propertyPath)) { - const { triggers } = this.getDynamicValue( - unEvalPropertyValue, - currentTree, - resolvedFunctions, - EvaluationSubstitutionType.TEMPLATE, - true, - undefined, - undefined, - fullPropertyPath, - ); - valueToValidate = triggers; + // TODO find a way to validate triggers + return; } const validation = widget.validationPaths[propertyPath]; const { isValid, messages, parsed, transformed } = validateWidgetProperty( validation, - valueToValidate, + evalPropertyValue, widget, ); @@ -995,7 +979,7 @@ export default class DataTreeEvaluator { if (correctFormat) { const body = entity.body.replace(/export default/g, ""); try { - const { result } = evaluate(body, unEvalDataTree, {}); + const { result } = evaluateSync(body, unEvalDataTree, {}); delete this.resolvedFunctions[`${entityName}`]; delete this.currentJSCollectionState[`${entityName}`]; if (result) { @@ -1005,10 +989,12 @@ export default class DataTreeEvaluator { const unEvalValue = result[unEvalFunc]; if (typeof unEvalValue === "function") { const params = getParams(unEvalValue); + const functionString = unEvalValue.toString(); actions.push({ name: unEvalFunc, - body: unEvalValue.toString(), + body: functionString, arguments: params, + isAsync: isFunctionAsync(unEvalValue, unEvalDataTree), }); _.set( this.resolvedFunctions, @@ -1018,7 +1004,7 @@ export default class DataTreeEvaluator { _.set( this.currentJSCollectionState, `${entityName}.${unEvalFunc}`, - unEvalValue.toString(), + functionString, ); } else { variables.push({ @@ -1547,7 +1533,6 @@ export default class DataTreeEvaluator { this.evalTree, this.resolvedFunctions, EvaluationSubstitutionType.TEMPLATE, - false, ); } @@ -1557,7 +1542,6 @@ export default class DataTreeEvaluator { this.evalTree, this.resolvedFunctions, EvaluationSubstitutionType.TEMPLATE, - false, // params can be accessed via "this.params" or "executionParams" { thisContext: { [THIS_DOT_PARAMS_KEY]: evaluatedExecutionParams }, @@ -1585,11 +1569,7 @@ export default class DataTreeEvaluator { jsSnippets[0], EvaluationScriptType.TRIGGERS, ); - const GLOBAL_DATA = createGlobalData( - currentTree, - this.resolvedFunctions, - true, - ); + const GLOBAL_DATA = createGlobalData(currentTree, this.resolvedFunctions); return getLintingErrors( script, diff --git a/app/client/src/workers/PromisifyAction.test.ts b/app/client/src/workers/PromisifyAction.test.ts new file mode 100644 index 0000000000..88f1ad3680 --- /dev/null +++ b/app/client/src/workers/PromisifyAction.test.ts @@ -0,0 +1,168 @@ +import { createGlobalData } from "workers/evaluate"; +import _ from "lodash"; +jest.mock("./evaluation.worker.ts", () => { + return { + dataTreeEvaluator: { + evalTree: {}, + resolvedFunctions: {}, + }, + }; +}); + +describe("promise execution", () => { + const postMessageMock = jest.fn(); + const requestId = _.uniqueId("TEST_REQUEST"); + const dataTreeWithFunctions = createGlobalData({}, {}, { requestId }); + + beforeEach(() => { + self.ALLOW_ASYNC = true; + self.postMessage = postMessageMock; + }); + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("throws when allow async is not enabled", () => { + self.ALLOW_ASYNC = false; + self.IS_ASYNC = false; + expect(dataTreeWithFunctions.showAlert).toThrowError(); + expect(self.IS_ASYNC).toBe(true); + expect(postMessageMock).not.toHaveBeenCalled(); + }); + it("sends an event from the worker", () => { + dataTreeWithFunctions.showAlert("test alert", "info"); + expect(postMessageMock).toBeCalledWith({ + requestId, + type: "PROCESS_TRIGGER", + responseData: expect.objectContaining({ + subRequestId: expect.stringContaining(`${requestId}_`), + trigger: { + type: "SHOW_ALERT", + payload: { + message: "test alert", + style: "info", + }, + }, + }), + }); + }); + it("returns a promise that resolves", async () => { + postMessageMock.mockReset(); + const returnedPromise = dataTreeWithFunctions.showAlert( + "test alert", + "info", + ); + const requestArgs = postMessageMock.mock.calls[0][0]; + const subRequestId = requestArgs.responseData.subRequestId; + + self.dispatchEvent( + new MessageEvent("message", { + data: { + data: { resolve: ["123"], subRequestId }, + method: "PROCESS_TRIGGER", + requestId, + success: true, + }, + }), + ); + + await expect(returnedPromise).resolves.toBe("123"); + }); + + it("returns a promise that rejects", async () => { + postMessageMock.mockReset(); + const returnedPromise = dataTreeWithFunctions.showAlert( + "test alert", + "info", + ); + const requestArgs = postMessageMock.mock.calls[0][0]; + const subRequestId = requestArgs.responseData.subRequestId; + self.dispatchEvent( + new MessageEvent("message", { + data: { + data: { reason: "testing", subRequestId }, + method: "PROCESS_TRIGGER", + requestId, + success: false, + }, + }), + ); + + await expect(returnedPromise).rejects.toBe("testing"); + }); + it("does not process till right event is triggered", async () => { + postMessageMock.mockReset(); + const returnedPromise = dataTreeWithFunctions.showAlert( + "test alert", + "info", + ); + + const requestArgs = postMessageMock.mock.calls[0][0]; + const correctSubRequestId = requestArgs.responseData.subRequestId; + const differentSubRequestId = "wrongRequestId"; + + self.dispatchEvent( + new MessageEvent("message", { + data: { + data: { + resolve: ["wrongRequest"], + subRequestId: differentSubRequestId, + }, + method: "PROCESS_TRIGGER", + requestId, + success: true, + }, + }), + ); + + self.dispatchEvent( + new MessageEvent("message", { + data: { + data: { resolve: ["testing"], subRequestId: correctSubRequestId }, + method: "PROCESS_TRIGGER", + requestId, + success: true, + }, + }), + ); + + await expect(returnedPromise).resolves.toBe("testing"); + }); + it("same subRequestId is not accepted again", async () => { + postMessageMock.mockReset(); + const returnedPromise = dataTreeWithFunctions.showAlert( + "test alert", + "info", + ); + + const requestArgs = postMessageMock.mock.calls[0][0]; + const subRequestId = requestArgs.responseData.subRequestId; + + self.dispatchEvent( + new MessageEvent("message", { + data: { + data: { + resolve: ["testing"], + subRequestId, + }, + method: "PROCESS_TRIGGER", + requestId, + success: true, + }, + }), + ); + + self.dispatchEvent( + new MessageEvent("message", { + data: { + data: { resolve: ["wrongRequest"], subRequestId }, + method: "PROCESS_TRIGGER", + requestId, + success: false, + }, + }), + ); + + await expect(returnedPromise).resolves.toBe("testing"); + }); +}); diff --git a/app/client/src/workers/PromisifyAction.ts b/app/client/src/workers/PromisifyAction.ts new file mode 100644 index 0000000000..96f2c66df7 --- /dev/null +++ b/app/client/src/workers/PromisifyAction.ts @@ -0,0 +1,100 @@ +import { createGlobalData, EvalResult } from "workers/evaluate"; +const ctx: Worker = self as any; + +/* + * We wrap all actions with a promise. The promise will send a message to the main thread + * and wait for a response till it can resolve or reject the promise. This way we can invoke actions + * in the main thread while evaluating in the main thread. In principle, all actions now work as promises. + * + * needs a REQUEST_ID to be passed in to know which request is going on right now + */ +import { EVAL_WORKER_ACTIONS } from "utils/DynamicBindingUtils"; +import { ActionDescription } from "entities/DataTree/actionTriggers"; +import _ from "lodash"; +import { dataTreeEvaluator } from "workers/evaluation.worker"; + +export const promisifyAction = ( + workerRequestId: string, + actionDescription: ActionDescription, +) => { + if (!self.ALLOW_ASYNC) { + /** + * To figure out if any function (JS action) is async, we do a dry run so that we can know if the function + * is using an async action. We set an IS_ASYNC flag to later indicate that a promise was called. + * @link isFunctionAsync + * */ + self.IS_ASYNC = true; + throw new Error("Async function called in a sync field"); + } + const workerRequestIdCopy = workerRequestId.concat(""); + return new Promise((resolve, reject) => { + // We create a new sub request id for each request going on so that we can resolve the correct one later on + const subRequestId = _.uniqueId(`${workerRequestIdCopy}_`); + // send an execution request to the main thread + const responseData = { + trigger: actionDescription, + errors: [], + subRequestId, + }; + ctx.postMessage({ + type: EVAL_WORKER_ACTIONS.PROCESS_TRIGGER, + responseData, + requestId: workerRequestIdCopy, + }); + const processResponse = function(event: MessageEvent) { + const { data, method, requestId, success } = event.data; + // This listener will get all the messages that come to the worker + // we need to find the correct one pertaining to this promise + if ( + method === EVAL_WORKER_ACTIONS.PROCESS_TRIGGER && + requestId === workerRequestIdCopy && + subRequestId === event.data.data.subRequestId + ) { + // If we get a response for this same promise we will resolve or reject it + + // We could not find a data tree evaluator, + // maybe the page changed, or we have a cyclical dependency + if (!dataTreeEvaluator) { + reject("No Data Tree Evaluator found"); + } else { + self.ALLOW_ASYNC = true; + // Reset the global data with the correct request id for this promise + const globalData = createGlobalData( + dataTreeEvaluator.evalTree, + dataTreeEvaluator.resolvedFunctions, + { + requestId: workerRequestId, + }, + ); + for (const entity in globalData) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + self[entity] = globalData[entity]; + } + + // Resolve or reject the promise + if (success) { + resolve.apply(self, data.resolve); + } else { + reject(data.reason); + } + } + // we are done with this particular promise so remove the event listener + ctx.removeEventListener("message", processResponse); + } + }; + ctx.addEventListener("message", processResponse); + }); +}; +// To indicate the main thread that the processing of the trigger is done +// we send a finished message +export const completePromise = (requestId: string, result: EvalResult) => { + ctx.postMessage({ + type: EVAL_WORKER_ACTIONS.PROCESS_TRIGGER, + responseData: { + finished: true, + result, + }, + requestId, + }); +}; diff --git a/app/client/src/workers/evaluate.test.ts b/app/client/src/workers/evaluate.test.ts index 2762cdaf4d..023f2f8f58 100644 --- a/app/client/src/workers/evaluate.test.ts +++ b/app/client/src/workers/evaluate.test.ts @@ -1,4 +1,8 @@ -import evaluate, { setupEvaluationEnvironment } from "workers/evaluate"; +import evaluate, { + setupEvaluationEnvironment, + evaluateAsync, + isFunctionAsync, +} from "workers/evaluate"; import { DataTree, DataTreeWidget, @@ -6,7 +10,7 @@ import { } from "entities/DataTree/dataTreeFactory"; import { RenderModes } from "constants/WidgetConstants"; -describe("evaluate", () => { +describe("evaluateSync", () => { const widget: DataTreeWidget = { bottomRow: 0, isLoading: false, @@ -58,7 +62,6 @@ describe("evaluate", () => { const response1 = evaluate("wrongJS", {}, {}); expect(response1).toStrictEqual({ result: undefined, - triggers: [], errors: [ { ch: 1, @@ -96,7 +99,6 @@ describe("evaluate", () => { const response2 = evaluate("{}.map()", {}, {}); expect(response2).toStrictEqual({ result: undefined, - triggers: [], errors: [ { errorMessage: "TypeError: {}.map is not a function", @@ -119,50 +121,11 @@ describe("evaluate", () => { const response = evaluate(js, dataTree, {}); expect(response.result).toBe("value"); }); - it("gets triggers from a function", () => { - const js = "showAlert('message', 'info')"; - const response = evaluate(js, dataTree, {}, undefined, undefined, true); - //this will be changed again in new implemenation for promises - const data = { - action: { - payload: { - executor: [ - { - payload: { message: "message", style: "info" }, - type: "SHOW_ALERT", - }, - ], - then: [], - }, - type: "PROMISE", - }, - triggerReference: 0, - }; - expect(response.result).toEqual(data); - expect(response.triggers).toStrictEqual([ - { - type: "PROMISE", - payload: { - executor: [ - { - type: "SHOW_ALERT", - payload: { - message: "message", - style: "info", - }, - }, - ], - then: [], - }, - }, - ]); - }); it("disallows unsafe function calls", () => { const js = "setTimeout(() => {}, 100)"; const response = evaluate(js, dataTree, {}); expect(response).toStrictEqual({ result: undefined, - triggers: [], errors: [ { errorMessage: "TypeError: setTimeout is not a function", @@ -202,20 +165,20 @@ describe("evaluate", () => { }); it("handles TRIGGERS with new lines", () => { let js = "\n"; - let response = evaluate(js, dataTree, {}, undefined, undefined, true); + let response = evaluate(js, dataTree, {}, undefined, undefined); expect(response.errors.length).toBe(0); js = "\n\n\n"; - response = evaluate(js, dataTree, {}, undefined, undefined, true); + response = evaluate(js, dataTree, {}, undefined, undefined); expect(response.errors.length).toBe(0); }); it("handles ANONYMOUS_FUNCTION with new lines", () => { let js = "\n"; - let response = evaluate(js, dataTree, {}, undefined, undefined, true); + let response = evaluate(js, dataTree, {}, undefined, undefined); expect(response.errors.length).toBe(0); js = "\n\n\n"; - response = evaluate(js, dataTree, {}, undefined, undefined, true); + response = evaluate(js, dataTree, {}, undefined, undefined); expect(response.errors.length).toBe(0); }); it("has access to this context", () => { @@ -235,3 +198,82 @@ describe("evaluate", () => { expect(response.errors).toHaveLength(0); }); }); + +describe("evaluateAsync", () => { + it("runs and completes", async () => { + const js = "(() => new Promise((resolve) => { resolve(123) }))()"; + self.postMessage = jest.fn(); + await evaluateAsync(js, {}, "TEST_REQUEST", {}); + expect(self.postMessage).toBeCalledWith({ + requestId: "TEST_REQUEST", + responseData: { + finished: true, + result: { errors: [], result: 123, triggers: [] }, + }, + type: "PROCESS_TRIGGER", + }); + }); + it("runs and returns errors", async () => { + jest.restoreAllMocks(); + const js = "(() => new Promise((resolve) => { randomKeyword }))()"; + self.postMessage = jest.fn(); + await evaluateAsync(js, {}, "TEST_REQUEST_1", {}); + expect(self.postMessage).toBeCalledWith({ + requestId: "TEST_REQUEST_1", + responseData: { + finished: true, + result: { + errors: [ + { + errorMessage: expect.stringContaining( + "randomKeyword is not defined", + ), + errorType: "PARSE", + originalBinding: expect.stringContaining("Promise"), + raw: expect.stringContaining("Promise"), + severity: "error", + }, + ], + triggers: [], + result: undefined, + }, + }, + type: "PROCESS_TRIGGER", + }); + }); +}); + +describe("isFunctionAsync", () => { + it("identifies async functions", () => { + // eslint-disable-next-line @typescript-eslint/ban-types + const cases: Array<{ script: Function | string; expected: boolean }> = [ + { + script: () => { + return 1; + }, + expected: false, + }, + { + script: () => { + return new Promise((resolve) => { + resolve(1); + }); + }, + expected: true, + }, + { + script: "() => { showAlert('yo') }", + expected: true, + }, + ]; + + for (const testCase of cases) { + let testFunc = testCase.script; + if (typeof testFunc === "string") { + testFunc = eval(testFunc); + } + const actual = isFunctionAsync(testFunc, {}); + expect(actual).toBe(testCase.expected); + } + }); +}); diff --git a/app/client/src/workers/evaluate.ts b/app/client/src/workers/evaluate.ts index a273f0355c..a46a9fb2e2 100644 --- a/app/client/src/workers/evaluate.ts +++ b/app/client/src/workers/evaluate.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import { DataTree } from "entities/DataTree/dataTreeFactory"; import { EvaluationError, @@ -7,15 +8,16 @@ import { } from "utils/DynamicBindingUtils"; import unescapeJS from "unescape-js"; import { Severity } from "entities/AppsmithConsole"; -import { AppsmithPromise, enhanceDataTreeWithFunctions } from "./Actions"; -import { ActionDescription } from "entities/DataTree/actionTriggers"; +import { enhanceDataTreeWithFunctions } from "./Actions"; import { isEmpty } from "lodash"; import { getLintingErrors } from "workers/lint"; +import { completePromise } from "workers/PromisifyAction"; +import { ActionDescription } from "entities/DataTree/actionTriggers"; export type EvalResult = { result: any; - triggers?: ActionDescription[]; errors: EvaluationError[]; + triggers?: ActionDescription[]; }; export enum EvaluationScriptType { @@ -43,20 +45,20 @@ export const EvaluationScripts: Record = { callback(${ScriptTemplate}) `, [EvaluationScriptType.TRIGGERS]: ` - function closedFunction () { - const result = ${ScriptTemplate} - return result + async function closedFunction () { + const result = await ${ScriptTemplate}; + return result; } closedFunction.call(THIS_CONTEXT); `, }; const getScriptType = ( - evalArguments?: Array, + evalArgumentsExist = false, isTriggerBased = false, ): EvaluationScriptType => { let scriptType = EvaluationScriptType.EXPRESSION; - if (evalArguments) { + if (evalArgumentsExist) { scriptType = EvaluationScriptType.ANONYMOUS_FUNCTION; } else if (isTriggerBased) { scriptType = EvaluationScriptType.TRIGGERS; @@ -94,7 +96,6 @@ const beginsWithLineBreakRegex = /^\s+|\s+$/; export const createGlobalData = ( dataTree: DataTree, resolvedFunctions: Record, - isTriggerBased: boolean, context?: EvaluateContext, evalArguments?: Array, ) => { @@ -113,20 +114,15 @@ export const createGlobalData = ( }); } } - ///// Mocking Promise class - GLOBAL_DATA.Promise = AppsmithPromise; - if (isTriggerBased) { - //// Add internal functions to dataTree; - const dataTreeWithFunctions = enhanceDataTreeWithFunctions(dataTree); - ///// Adding Data tree with functions - Object.keys(dataTreeWithFunctions).forEach((datum) => { - GLOBAL_DATA[datum] = dataTreeWithFunctions[datum]; - }); - } else { - Object.keys(dataTree).forEach((datum) => { - GLOBAL_DATA[datum] = dataTree[datum]; - }); - } + //// Add internal functions to dataTree; + const dataTreeWithFunctions = enhanceDataTreeWithFunctions( + dataTree, + context?.requestId, + ); + ///// Adding Data tree with functions + Object.keys(dataTreeWithFunctions).forEach((datum) => { + GLOBAL_DATA[datum] = dataTreeWithFunctions[datum]; + }); if (!isEmpty(resolvedFunctions)) { Object.keys(resolvedFunctions).forEach((datum: any) => { const resolvedObject = resolvedFunctions[datum]; @@ -156,45 +152,71 @@ export function sanitizeScript(js: string) { export type EvaluateContext = { thisContext?: Record; globalContext?: Record; + requestId?: string; }; -export default function evaluate( - js: string, - data: DataTree, +export const getUserScriptToEvaluate = ( + userScript: string, + GLOBAL_DATA: Record, + isTriggerBased: boolean, + evalArguments?: Array, +) => { + const unescapedJS = sanitizeScript(userScript); + // If nothing is present to evaluate, return instead of linting + if (!unescapedJS.length) { + return { + lintErrors: [], + script: "", + }; + } + const scriptType = getScriptType(!!evalArguments, isTriggerBased); + const script = getScriptToEval(unescapedJS, scriptType); + // We are linting original js binding, + // This will make sure that the character count is not messed up when we do unescapejs + const scriptToLint = getScriptToEval(userScript, scriptType); + const lintErrors = getLintingErrors( + scriptToLint, + GLOBAL_DATA, + userScript, + scriptType, + ); + return { script, lintErrors }; +}; + +export default function evaluateSync( + userScript: string, + dataTree: DataTree, resolvedFunctions: Record, context?: EvaluateContext, evalArguments?: Array, - isTriggerBased = false, ): EvalResult { - const sanitizedScript = sanitizeScript(js); - - // If nothing is present to evaluate, return back instead of evaluating - if (!sanitizedScript.length) { - return { - errors: [], - result: undefined, - triggers: [], - }; - } - - const scriptType = getScriptType(evalArguments, isTriggerBased); - const script = getScriptToEval(sanitizedScript, scriptType); - // We are linting original js binding, - // This will make sure that the character count is not messed up when we do unescapejs - const scriptToLint = getScriptToEval(js, scriptType); - return (function() { let errors: EvaluationError[] = []; let result; - let triggers: any[] = []; /**** Setting the eval context ****/ const GLOBAL_DATA: Record = createGlobalData( - data, + dataTree, resolvedFunctions, - isTriggerBased, context, evalArguments, ); + GLOBAL_DATA.ALLOW_ASYNC = false; + const { lintErrors, script } = getUserScriptToEvaluate( + userScript, + GLOBAL_DATA, + false, + evalArguments, + ); + // If nothing is present to evaluate, return instead of evaluating + if (!script.length) { + return { + errors: [], + result: undefined, + triggers: [], + }; + } + + errors = lintErrors; // Set it to self so that the eval function can have access to it // as global data. This is what enables access all appsmith @@ -204,14 +226,9 @@ export default function evaluate( // @ts-ignore: No types available self[entity] = GLOBAL_DATA[entity]; } - errors = getLintingErrors(scriptToLint, GLOBAL_DATA, js, scriptType); try { result = eval(script); - if (isTriggerBased) { - triggers = self.triggers.slice(); - self.triggers = []; - } } catch (e) { const errorMessage = `${e.name}: ${e.message}`; errors.push({ @@ -219,28 +236,120 @@ export default function evaluate( severity: Severity.ERROR, raw: script, errorType: PropertyEvaluationErrorType.PARSE, - originalBinding: js, + originalBinding: userScript, }); + } finally { + for (const entity in GLOBAL_DATA) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: No types available + delete self[entity]; + } } - if (!isEmpty(resolvedFunctions)) { - Object.keys(resolvedFunctions).forEach((datum: any) => { - const resolvedObject = resolvedFunctions[datum]; - Object.keys(resolvedObject).forEach((key: any) => { - if (resolvedObject[key]) { - self[datum][key] = resolvedObject[key].toString(); - } - }); - }); - } - // Remove it from self - // This is needed so that next eval can have a clean sheet + return { result, errors }; + })(); +} + +export async function evaluateAsync( + userScript: string, + dataTree: DataTree, + requestId: string, + resolvedFunctions: Record, + context?: EvaluateContext, + evalArguments?: Array, +) { + return (async function() { + const errors: EvaluationError[] = []; + let result; + /**** Setting the eval context ****/ + const GLOBAL_DATA: Record = createGlobalData( + dataTree, + resolvedFunctions, + { ...context, requestId }, + evalArguments, + ); + const { script } = getUserScriptToEvaluate( + userScript, + GLOBAL_DATA, + true, + evalArguments, + ); + GLOBAL_DATA.ALLOW_ASYNC = true; + // Set it to self so that the eval function can have access to it + // as global data. This is what enables access all appsmith + // entity properties from the global context Object.keys(GLOBAL_DATA).forEach((key) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: No types available - delete self[key]; + self[key] = GLOBAL_DATA[key]; }); - return { result, triggers, errors }; + try { + result = await eval(script); + } catch (error) { + const errorMessage = `UncaughtPromiseRejection: ${error.message}`; + errors.push({ + errorMessage: errorMessage, + severity: Severity.ERROR, + raw: script, + errorType: PropertyEvaluationErrorType.PARSE, + originalBinding: userScript, + }); + } finally { + completePromise(requestId, { + result, + errors, + triggers: Array.from(self.TRIGGER_COLLECTOR), + }); + for (const entity in GLOBAL_DATA) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: No types available + delete self[entity]; + } + } + })(); +} + +export function isFunctionAsync(userFunction: unknown, dataTree: DataTree) { + return (function() { + /**** Setting the eval context ****/ + const GLOBAL_DATA: Record = { + ALLOW_ASYNC: false, + IS_ASYNC: false, + }; + //// Add internal functions to dataTree; + const dataTreeWithFunctions = enhanceDataTreeWithFunctions(dataTree); + ///// Adding Data tree with functions + Object.keys(dataTreeWithFunctions).forEach((datum) => { + GLOBAL_DATA[datum] = dataTreeWithFunctions[datum]; + }); + // Set it to self so that the eval function can have access to it + // as global data. This is what enables access all appsmith + // entity properties from the global context + Object.keys(GLOBAL_DATA).forEach((key) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: No types available + self[key] = GLOBAL_DATA[key]; + }); + try { + if (typeof userFunction === "function") { + const returnValue = userFunction(); + if (!!returnValue && returnValue instanceof Promise) { + self.IS_ASYNC = true; + } + if (self.TRIGGER_COLLECTOR.length) { + self.IS_ASYNC = true; + } + } + } catch (e) { + console.error("Error when determining async function", e); + } + const isAsync = !!self.IS_ASYNC; + for (const entity in GLOBAL_DATA) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: No types available + delete self[entity]; + } + return isAsync; })(); } diff --git a/app/client/src/workers/evaluation.worker.ts b/app/client/src/workers/evaluation.worker.ts index bfbe50dd13..477a6a36e3 100644 --- a/app/client/src/workers/evaluation.worker.ts +++ b/app/client/src/workers/evaluation.worker.ts @@ -1,14 +1,11 @@ -import { - DataTree, - EvaluationSubstitutionType, -} from "entities/DataTree/dataTreeFactory"; +// Workers do not have access to log.error +/* eslint-disable no-console */ +import { DataTree } from "entities/DataTree/dataTreeFactory"; import { DependencyMap, EVAL_WORKER_ACTIONS, EvalError, EvalErrorTypes, - EvaluationError, - PropertyEvaluationErrorType, } from "utils/DynamicBindingUtils"; import { CrashingError, @@ -19,59 +16,69 @@ import { } from "./evaluationUtils"; import DataTreeEvaluator from "workers/DataTreeEvaluator"; import ReplayEntity from "entities/Replay"; -import evaluate, { setupEvaluationEnvironment } from "workers/evaluate"; +import evaluate, { + evaluateAsync, + setupEvaluationEnvironment, +} from "workers/evaluate"; import ReplayCanvas from "entities/Replay/ReplayEntity/ReplayCanvas"; import ReplayEditor from "entities/Replay/ReplayEntity/ReplayEditor"; -import * as log from "loglevel"; const CANVAS = "canvas"; const ctx: Worker = self as any; -let dataTreeEvaluator: DataTreeEvaluator | undefined; +export let dataTreeEvaluator: DataTreeEvaluator | undefined; let replayMap: Record>; //TODO: Create a more complete RPC setup in the subtree-eval branch. function messageEventListener( - fn: (message: EVAL_WORKER_ACTIONS, requestData: any) => void, + fn: ( + message: EVAL_WORKER_ACTIONS, + requestData: any, + requestId: string, + ) => any, ) { return (e: MessageEvent) => { const startTime = performance.now(); const { method, requestData, requestId } = e.data; - const responseData = fn(method, requestData); - const endTime = performance.now(); - try { - ctx.postMessage({ - requestId, - responseData, - timeTaken: (endTime - startTime).toFixed(2), - }); - } catch (e) { - log.error(e); - // we dont want to log dataTree because it is huge. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { dataTree, ...rest } = requestData; - ctx.postMessage({ - requestId, - responseData: { - errors: [ - { - type: EvalErrorTypes.CLONE_ERROR, - message: e, - context: JSON.stringify(rest), + if (method) { + const responseData = fn(method, requestData, requestId); + if (responseData) { + const endTime = performance.now(); + try { + ctx.postMessage({ + requestId, + responseData, + timeTaken: (endTime - startTime).toFixed(2), + }); + } catch (e) { + console.error(e); + // we dont want to log dataTree because it is huge. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { dataTree, ...rest } = requestData; + ctx.postMessage({ + requestId, + responseData: { + errors: [ + { + type: EvalErrorTypes.CLONE_ERROR, + message: e, + context: JSON.stringify(rest), + }, + ], }, - ], - }, - timeTaken: (endTime - startTime).toFixed(2), - }); + timeTaken: (endTime - startTime).toFixed(2), + }); + } + } } }; } ctx.addEventListener( "message", - messageEventListener((method, requestData: any) => { + messageEventListener((method, requestData: any, requestId) => { switch (method) { case EVAL_WORKER_ACTIONS.SETUP: { setupEvaluationEnvironment(); @@ -134,7 +141,7 @@ ctx.addEventListener( type: EvalErrorTypes.UNKNOWN_ERROR, message: e.message, }); - log.error(e); + console.error(e); } dataTree = getSafeToRenderDataTree(unevalTree, widgetTypeConfigMap); dataTreeEvaluator = undefined; @@ -167,51 +174,30 @@ ctx.addEventListener( return { values: cleanValues, errors }; } case EVAL_WORKER_ACTIONS.EVAL_TRIGGER: { - const { - callbackData, - dataTree, - dynamicTrigger, - fullPropertyPath, - } = requestData; + const { callbackData, dataTree, dynamicTrigger } = requestData; if (!dataTreeEvaluator) { return { triggers: [], errors: [] }; } dataTreeEvaluator.updateDataTree(dataTree); const evalTree = dataTreeEvaluator.evalTree; const resolvedFunctions = dataTreeEvaluator.resolvedFunctions; - const { - errors: evalErrors, - result, - triggers, - }: { - errors: EvaluationError[]; - triggers: Array; - result: any; - } = dataTreeEvaluator.getDynamicValue( + + dataTreeEvaluator.evaluateTriggers( dynamicTrigger, evalTree, + requestId, resolvedFunctions, - EvaluationSubstitutionType.TEMPLATE, - true, - undefined, callbackData, - fullPropertyPath, ); - const cleanTriggers = removeFunctions(triggers); - const cleanResult = removeFunctions(result); - // Transforming eval errors into eval trigger errors. Since trigger - // errors occur less, we want to treat it separately - const errors = evalErrors - .filter( - (error) => error.errorType === PropertyEvaluationErrorType.PARSE, - ) - .map((error) => ({ - ...error, - message: error.errorMessage, - type: EvalErrorTypes.EVAL_TRIGGER_ERROR, - })); - return { triggers: cleanTriggers, errors, result: cleanResult }; + + break; } + case EVAL_WORKER_ACTIONS.PROCESS_TRIGGER: + /** + * This action will not be processed here. This is handled in the eval trigger sub steps + * @link promisifyAction + **/ + break; case EVAL_WORKER_ACTIONS.CLEAR_CACHE: { dataTreeEvaluator = undefined; return true; @@ -252,31 +238,29 @@ ctx.addEventListener( replayMap[entityId ?? CANVAS].clearLogs(); return replayResult; } - case EVAL_WORKER_ACTIONS.EVAL_JS_FUNCTION: { - const { action, collectionName } = requestData; + case EVAL_WORKER_ACTIONS.EXECUTE_SYNC_JS: { + const { functionCall } = requestData; if (!dataTreeEvaluator) { return true; } const evalTree = dataTreeEvaluator.evalTree; const resolvedFunctions = dataTreeEvaluator.resolvedFunctions; - const path = collectionName + "." + action.name + "()"; - const { result } = evaluate( - path, + const { errors, result } = evaluate( + functionCall, evalTree, resolvedFunctions, undefined, - undefined, - true, ); - return result; + return { errors, result }; } case EVAL_WORKER_ACTIONS.EVAL_EXPRESSION: const { expression, isTrigger } = requestData; const evalTree = dataTreeEvaluator?.evalTree; if (!evalTree) return {}; + // TODO find a way to do this for snippets return isTrigger - ? evaluate(expression, evalTree, {}, undefined, [], true) + ? evaluateAsync(expression, evalTree, "SNIPPET", {}) : evaluate(expression, evalTree, {}); case EVAL_WORKER_ACTIONS.UPDATE_REPLAY_OBJECT: const { entity, entityId, entityType } = requestData; @@ -292,7 +276,7 @@ ctx.addEventListener( self.evaluationVersion = version || 1; break; default: { - log.error("Action not registered on worker", method); + console.error("Action not registered on worker", method); } } }), diff --git a/app/client/src/workers/evaluationUtils.ts b/app/client/src/workers/evaluationUtils.ts index cf9d7ec827..de8f86324a 100644 --- a/app/client/src/workers/evaluationUtils.ts +++ b/app/client/src/workers/evaluationUtils.ts @@ -24,8 +24,9 @@ import _ from "lodash"; import { WidgetTypeConfigMap } from "utils/WidgetFactory"; import { ValidationConfig } from "constants/PropertyControlConstants"; import { Severity } from "entities/AppsmithConsole"; -import { Variable } from "entities/JSCollection"; import { ParsedBody, ParsedJSSubAction } from "utils/JSPaneUtils"; +import { Variable } from "entities/JSCollection"; + // Dropdown1.options[1].value -> Dropdown1.options[1] // Dropdown1.options[1] -> Dropdown1.options // Dropdown1.options -> Dropdown1