Adding JS execution functionality.

This commit is contained in:
Satbir 2019-11-28 09:26:44 +05:30
parent 0ffe94a298
commit 48fa52d9ba
16 changed files with 298 additions and 39 deletions

View File

@ -3,6 +3,7 @@
<head>
<script type="text/javascript" src="https://identity.netlify.com/v1/netlify-identity-widget.js"></script>
<script type="text/javascript" src="/shims/realms-shim.umd.min.js"></script>
<meta charset="utf-8" />
<!-- in index.html, or however you manage your CSS files -->
<link href="../node_modules/normalize.css/normalize.css" rel="stylesheet" />

File diff suppressed because one or more lines are too long

View File

@ -27,7 +27,7 @@ export const PropertyPaneActionDropdownOptions: DropdownOption[] = [
// { label: "Run Query", value: "QUERY" },
];
export interface ActionPayload {
export interface BaseActionPayload {
actionId: string;
actionType: ActionType;
contextParams: Record<string, string>;
@ -35,34 +35,42 @@ export interface ActionPayload {
onError?: ActionPayload[];
}
export type ActionPayload =
| NavigateActionPayload
| SetValueActionPayload
| ExecuteJSActionPayload
| DownloadDataActionPayload
| SetValueActionPayload;
export type NavigationType = "NEW_TAB" | "INLINE";
export interface NavigateActionPayload extends ActionPayload {
export interface NavigateActionPayload extends BaseActionPayload {
pageUrl: string;
navigationType: NavigationType;
}
export interface ShowAlertActionPayload extends ActionPayload {
export interface ShowAlertActionPayload extends BaseActionPayload {
header: string;
message: string;
alertType: AlertType;
intent: MessageIntent;
}
export interface SetValueActionPayload extends ActionPayload {
export interface SetValueActionPayload extends BaseActionPayload {
header: string;
message: string;
alertType: AlertType;
intent: MessageIntent;
}
export interface ExecuteJSActionPayload extends ActionPayload {
export interface ExecuteJSActionPayload extends BaseActionPayload {
jsFunctionId: string;
jsFunction: string;
}
export type DownloadFiletype = "CSV" | "XLS" | "JSON" | "TXT";
export interface DownloadDataActionPayload extends ActionPayload {
export interface DownloadDataActionPayload extends BaseActionPayload {
data: JSON;
fileName: string;
fileType: DownloadFiletype;

View File

@ -2,5 +2,6 @@
// TODO (hetu): Remove useless escapes and re-enable the above lint rule
export type NamePathBindingMap = Record<string, string>;
export const DATA_BIND_REGEX = /{{(\s*[\w\.\[\]\d]+\s*)}}/g;
export const DATA_BIND_JS_REGEX = /(.*?){{(\s*(.*?)\s*)}}(.*?)/g;
export const DATA_PATH_REGEX = /[\w\.\[\]\d]+/;
/* eslint-enable no-useless-escape */

View File

@ -121,6 +121,7 @@ export const ReduxActionErrorTypes: { [key: string]: string } = {
FETCH_PAGE_LIST_ERROR: "FETCH_PAGE_LIST_ERROR",
FETCH_APPLICATION_LIST_ERROR: "FETCH_APPLICATION_LIST_ERROR",
CREATE_APPLICATION_ERROR: "CREATE_APPLICATION_ERROR",
SAVE_JS_EXECUTION_RECORD: "SAVE_JS_EXECUTION_RECORD",
};
export const ReduxFormActionTypes: { [key: string]: string } = {

View File

@ -0,0 +1,51 @@
import RealmExecutor from "./RealmExecutor";
export type JSExecutorGlobal = Record<string, object>;
export interface JSExecutor {
execute: (src: string, data: JSExecutorGlobal) => string;
registerLibrary: (accessor: string, lib: any) => void;
unRegisterLibrary: (accessor: string) => void;
}
enum JSExecutorType {
REALM,
}
class JSExecutionManager {
currentExecutor: JSExecutor;
executors: Record<JSExecutorType, JSExecutor>;
registerLibrary(accessor: string, lib: any) {
Object.keys(this.executors).forEach(type => {
const executor = this.executors[(type as any) as JSExecutorType];
executor.registerLibrary(accessor, lib);
});
}
unRegisterLibrary(accessor: string) {
Object.keys(this.executors).forEach(type => {
const executor = this.executors[(type as any) as JSExecutorType];
executor.unRegisterLibrary(accessor);
});
}
switchExecutor(type: JSExecutorType) {
const executor = this.executors[type];
if (!executor) {
throw new Error("Executor does not exist");
}
this.currentExecutor = executor;
}
constructor() {
const realmExecutor = new RealmExecutor();
this.executors = {
[JSExecutorType.REALM]: realmExecutor,
};
this.currentExecutor = realmExecutor;
this.registerLibrary("_", window._);
}
evaluateSync(jsSrc: string, data: JSExecutorGlobal) {
return this.currentExecutor.execute(jsSrc, data);
}
}
const JSExecutionManagerSingleton = new JSExecutionManager();
export default JSExecutionManagerSingleton;

View File

@ -0,0 +1,42 @@
import { JSExecutorGlobal, JSExecutor } from "./JSExecutionManagerSingleton";
declare let Realm: any;
export default class RealmExecutor implements JSExecutor {
rootRealm: any;
creaetSafeObject: any;
extrinsics: any[] = [];
createSafeFunction: (unsafeFn: Function) => Function;
libraries: Record<string, any> = {};
constructor() {
this.rootRealm = Realm.makeRootRealm();
this.createSafeFunction = this.rootRealm.evaluate(`
(function createSafeFunction(unsafeFn) {
return function safeFn(...args) {
unsafeFn(...args);
}
})
`);
this.creaetSafeObject = this.rootRealm.evaluate(`
(function creaetSafeObject(unsafeObject) {
return JSON.parse(JSON.stringify(unsafeObject));
})
`);
}
registerLibrary(accessor: string, lib: any) {
this.rootRealm.global[accessor] = lib;
}
unRegisterLibrary(accessor: string) {
this.rootRealm.global[accessor] = null;
}
execute(sourceText: string, data: JSExecutorGlobal) {
const safeData = this.creaetSafeObject(data);
let result;
try {
result = this.rootRealm.evaluate(sourceText, safeData);
} catch (e) {
result = `Error: ${e}`;
}
return result;
}
}

View File

@ -8,6 +8,7 @@ import propertyPaneConfigReducer from "./propertyPaneConfigReducer";
import datasourceReducer from "./datasourceReducer";
import bindingsReducer from "./bindingsReducer";
import pageListReducer from "./pageListReducer";
import jsExecutionsReducer from "./jsExecutionsReducer";
const entityReducer = combineReducers({
canvasWidgets: canvasWidgetsReducer,
@ -19,6 +20,7 @@ const entityReducer = combineReducers({
datasources: datasourceReducer,
nameBindings: bindingsReducer,
pageList: pageListReducer,
jsExecutions: jsExecutionsReducer,
});
export default entityReducer;

View File

@ -0,0 +1,21 @@
import { createReducer } from "../../utils/AppsmithUtils";
import {
ReduxActionTypes,
ReduxAction,
} from "../../constants/ReduxActionConstants";
export type JSExecutionRecord = Record<string, string>;
const initialState: JSExecutionRecord = {};
const jsExecutionsReducer = createReducer(initialState, {
[ReduxActionTypes.SAVE_JS_EXECUTION_RECORD]: (
state: JSExecutionRecord,
action: ReduxAction<JSExecutionRecord>,
) => {
return {
...state,
...action.payload,
};
},
});
export default jsExecutionsReducer;

View File

@ -12,7 +12,11 @@ import {
takeEvery,
takeLatest,
} from "redux-saga/effects";
import { ActionPayload, PageAction } from "constants/ActionConstants";
import {
ActionPayload,
PageAction,
ExecuteJSActionPayload,
} from "constants/ActionConstants";
import ActionAPI, {
ActionApiResponse,
ActionCreateUpdateResponse,
@ -35,6 +39,7 @@ import {
extractDynamicBoundValue,
getDynamicBindings,
isDynamicValue,
NameBindingsWithData,
} from "utils/DynamicBindingUtils";
import { validateResponse } from "./ErrorSagas";
import { getDataTree } from "selectors/entitiesSelector";
@ -44,6 +49,8 @@ import {
} from "constants/messages";
import { getFormData } from "selectors/formSelectors";
import { API_EDITOR_FORM_NAME } from "constants/forms";
import JSExecutionManagerSingleton from "jsExecution/JSExecutionManagerSingleton";
import { getNameBindingsWithData } from "selectors/nameBindingsWithDataSelector";
export const getAction = (
state: AppState,
@ -90,6 +97,23 @@ export function* getActionParams(jsonPathKeys: string[] | undefined) {
return mapToPropList(dynamicBindings);
}
function* executeJSActionSaga(jsAction: ExecuteJSActionPayload) {
const nameBindingsWithData: NameBindingsWithData = yield select(
getNameBindingsWithData,
);
const result = JSExecutionManagerSingleton.evaluateSync(
jsAction.jsFunction,
nameBindingsWithData,
);
yield put({
type: ReduxActionTypes.SAVE_JS_EXECUTION_RECORD,
payload: {
[jsAction.jsFunctionId]: result,
},
});
}
export function* executeAPIQueryActionSaga(apiAction: ActionPayload) {
try {
const api: PageAction = yield select(getAction, apiAction.actionId);
@ -193,6 +217,11 @@ export function* executeActionSaga(actionPayloads: ActionPayload[]): any {
return call(executeAPIQueryActionSaga, actionPayload);
case "QUERY":
return call(executeAPIQueryActionSaga, actionPayload);
case "JS_FUNCTION":
return call(
executeJSActionSaga,
actionPayload as ExecuteJSActionPayload,
);
default:
return undefined;
}

View File

@ -7,7 +7,10 @@ import { WidgetConfigReducerState } from "reducers/entityReducers/widgetConfigRe
import { WidgetCardProps } from "widgets/BaseWidget";
import { WidgetSidebarReduxState } from "reducers/uiReducers/widgetSidebarReducer";
import CanvasWidgetsNormalizer from "normalizers/CanvasWidgetsNormalizer";
import { enhanceWithDynamicValuesAndValidations } from "utils/DynamicBindingUtils";
import {
enhanceWithDynamicValuesAndValidations,
NameBindingsWithData,
} from "utils/DynamicBindingUtils";
import { getDataTree } from "./entitiesSelector";
import {
FlattenedWidgetProps,
@ -17,6 +20,7 @@ import { PageListReduxState } from "reducers/entityReducers/pageListReducer";
import { OccupiedSpace } from "constants/editorConstants";
import { WidgetTypes } from "constants/WidgetConstants";
import { getNameBindingsWithData } from "./nameBindingsWithDataSelector";
const getEditorState = (state: AppState) => state.ui.editor;
const getWidgetConfigs = (state: AppState) => state.entities.widgetConfig;
@ -112,12 +116,13 @@ export const getWidgetCards = createSelector(
export const getValidatedDynamicProps = createSelector(
getDataTree,
(entities: DataTree) => {
getNameBindingsWithData,
(entities: DataTree, nameBindingsWithData: NameBindingsWithData) => {
const widgets = { ...entities.canvasWidgets };
Object.keys(widgets).forEach(widgetKey => {
widgets[widgetKey] = enhanceWithDynamicValuesAndValidations(
widgets[widgetKey],
entities,
nameBindingsWithData,
true,
);
});

View File

@ -0,0 +1,24 @@
import { DataTree } from "reducers";
import { NameBindingsWithData } from "utils/DynamicBindingUtils";
import { JSONPath } from "jsonpath-plus";
import { createSelector } from "reselect";
import { getDataTree } from "./entitiesSelector";
export const getNameBindingsWithData = createSelector(
getDataTree,
(dataTree: DataTree): NameBindingsWithData => {
const nameBindingsWithData: Record<string, object> = {};
Object.keys(dataTree.nameBindings).forEach(key => {
const nameBindings = dataTree.nameBindings[key];
const evaluatedValue = JSONPath({
path: nameBindings,
json: dataTree,
})[0];
if (evaluatedValue && key !== "undefined") {
nameBindingsWithData[key] = evaluatedValue;
}
});
return nameBindingsWithData;
},
);

View File

@ -1,12 +1,15 @@
import { createSelector } from "reselect";
import { AppState, DataTree } from "reducers";
import { AppState } from "reducers";
import { PropertyPaneReduxState } from "reducers/uiReducers/propertyPaneReducer";
import { PropertyPaneConfigState } from "reducers/entityReducers/propertyPaneConfigReducer";
import { CanvasWidgetsReduxState } from "reducers/entityReducers/canvasWidgetsReducer";
import { PropertySection } from "reducers/entityReducers/propertyPaneConfigReducer";
import { getDataTree } from "./entitiesSelector";
import { enhanceWithDynamicValuesAndValidations } from "utils/DynamicBindingUtils";
import {
enhanceWithDynamicValuesAndValidations,
NameBindingsWithData,
} from "utils/DynamicBindingUtils";
import { WidgetProps } from "widgets/BaseWidget";
import { getNameBindingsWithData } from "./nameBindingsWithDataSelector";
const getPropertyPaneState = (state: AppState): PropertyPaneReduxState =>
state.ui.propertyPane;
@ -35,10 +38,17 @@ export const getCurrentWidgetProperties = createSelector(
export const getWidgetPropsWithValidations = createSelector(
getCurrentWidgetProperties,
getDataTree,
(widget: WidgetProps | undefined, dataTree: DataTree) => {
getNameBindingsWithData,
(
widget: WidgetProps | undefined,
nameBindigsWithData: NameBindingsWithData,
) => {
if (!widget) return undefined;
return enhanceWithDynamicValuesAndValidations(widget, dataTree, false);
return enhanceWithDynamicValuesAndValidations(
widget,
nameBindigsWithData,
false,
);
},
);

View File

@ -1,22 +1,67 @@
import _ from "lodash";
import { DataTree } from "reducers";
import { JSONPath } from "jsonpath-plus";
import { WidgetProps } from "widgets/BaseWidget";
import { DATA_BIND_REGEX, DATA_PATH_REGEX } from "constants/BindingsConstants";
import { DATA_BIND_JS_REGEX } from "constants/BindingsConstants";
import ValidationFactory from "./ValidationFactory";
import JSExecutionManagerSingleton from "jsExecution/JSExecutionManagerSingleton";
export type NameBindingsWithData = Record<string, object>;
export const isDynamicValue = (value: string): boolean =>
DATA_BIND_REGEX.test(value);
DATA_BIND_JS_REGEX.test(value);
//{{}}{{}}}
function parseDynamicString(dynamicString: string): string[] {
let parsedDynamicValues = [];
const indexOfDoubleParanStart = dynamicString.indexOf("{{");
if (indexOfDoubleParanStart === -1) {
return [dynamicString];
}
//{{}}{{}}}
const firstString = dynamicString.substring(0, indexOfDoubleParanStart);
firstString && parsedDynamicValues.push(firstString);
let rest = dynamicString.substring(
indexOfDoubleParanStart,
dynamicString.length,
);
//{{}}{{}}}
let sum = 0;
for (let i = 0; i <= rest.length - 1; i++) {
const char = rest[i];
const prevChar = rest[i - 1];
if (char === "{") {
sum++;
} else if (char === "}") {
sum--;
if (prevChar === "}" && sum === 0) {
parsedDynamicValues.push(rest.substring(0, i + 1));
rest = rest.substring(i + 1, rest.length);
if (rest) {
parsedDynamicValues = parsedDynamicValues.concat(
parseDynamicString(rest),
);
break;
}
}
}
}
if (sum !== 0 && dynamicString !== "") {
return [dynamicString];
}
return parsedDynamicValues;
}
export const getDynamicBindings = (
dynamicString: string,
): { bindings: string[]; paths: string[] } => {
// Get the {{binding}} bound values
const bindings = dynamicString.match(DATA_BIND_REGEX) || [];
const bindings = parseDynamicString(dynamicString);
// Get the "binding" path values
const paths = bindings.map(p => {
const matches = p.match(DATA_PATH_REGEX);
if (matches) return matches[0];
const paths = bindings.map(binding => {
const length = binding.length;
const matches = binding.match(DATA_BIND_JS_REGEX);
if (matches) {
return binding.substring(2, length - 2);
}
return "";
});
return { bindings, paths };
@ -24,17 +69,10 @@ export const getDynamicBindings = (
// Paths are expected to have "{name}.{path}" signature
export const extractDynamicBoundValue = (
dataTree: DataTree,
data: NameBindingsWithData,
path: string,
): any => {
// Remove the name in the binding
const splitPath = path.split(".");
// Find the dataTree path of the name
const bindingPath = dataTree.nameBindings[splitPath[0]];
// Create the full path
const fullPath = `${bindingPath}.${splitPath.slice(1).join(".")}`;
// Search with JSONPath
return JSONPath({ path: fullPath, json: dataTree })[0];
return JSExecutionManagerSingleton.evaluateSync(path, data);
};
// For creating a final value where bindings could be in a template format
@ -57,13 +95,16 @@ export const createDynamicValueString = (
export const getDynamicValue = (
dynamicBinding: string,
dataTree: DataTree,
data: NameBindingsWithData,
): any => {
// Get the {{binding}} bound values
const { bindings, paths } = getDynamicBindings(dynamicBinding);
if (bindings.length) {
// Get the Data Tree value of those "binding "paths
const values = paths.map(p => extractDynamicBoundValue(dataTree, p));
const values = paths.map((p, i) => {
return p ? extractDynamicBoundValue(data, p) : bindings[i];
});
// if it is just one binding, no need to create template string
if (bindings.length === 1) return values[0];
// else return a string template with bindings
@ -74,17 +115,19 @@ export const getDynamicValue = (
export const enhanceWithDynamicValuesAndValidations = (
widget: WidgetProps,
entities: DataTree,
nameBindingsWithData: NameBindingsWithData,
replaceWithParsed: boolean,
): WidgetProps => {
if (!widget) return widget;
const properties = { ...widget };
const invalidProps: Record<string, boolean> = {};
const t0 = performance.now();
Object.keys(widget).forEach((property: string) => {
let value = widget[property];
// Check for dynamic bindings
if (widget.dynamicBindings && property in widget.dynamicBindings) {
value = getDynamicValue(value, entities);
value = getDynamicValue(value, nameBindingsWithData);
}
// Pass it through validation and parse
const { isValid, parsed } = ValidationFactory.validateWidgetProperty(
@ -97,5 +140,14 @@ export const enhanceWithDynamicValuesAndValidations = (
// Replace if flag is turned on
if (replaceWithParsed) properties[property] = parsed;
});
const t1 = performance.now();
console.log(
"Evaluations for " +
widget.widgetName +
" took " +
(t1 - t0) +
" milliseconds.",
);
console.trace();
return { ...properties, invalidProps };
};

View File

@ -2,7 +2,7 @@ import React from "react";
import _ from "lodash";
import BaseWidget, { WidgetProps, WidgetState } from "./BaseWidget";
import { WidgetType } from "constants/WidgetConstants";
import { ActionPayload } from "constants/ActionConstants";
import { ActionPayload, BaseActionPayload } from "constants/ActionConstants";
import { AutoResizer } from "react-base-table";
import "react-base-table/styles.css";
import { forIn } from "lodash";
@ -94,7 +94,7 @@ class TableWidget extends BaseWidget<TableWidgetProps, WidgetState> {
export type PaginationType = "PAGES" | "INFINITE_SCROLL";
export interface TableAction extends ActionPayload {
export interface TableAction extends BaseActionPayload {
actionName: string;
}

2
app/client/typings/Realm/index.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
// import * as React from "react";
declare module "Realm";