PromucFlow_constructor/app/client/src/utils/DynamicBindingUtils.ts
Hetu Nandu 4a6717889c
Streamline action save with widgets (#10)
- Remove drafts from actions
- Direct update action from forms
- Debounced saving of actions
- Add org id in default datasource
- Merge query and api run saga
2020-07-03 14:28:58 +05:30

750 lines
22 KiB
TypeScript

import _ from "lodash";
import {
DATA_BIND_REGEX,
DATA_BIND_REGEX_GLOBAL,
} from "constants/BindingsConstants";
import ValidationFactory from "./ValidationFactory";
import JSExecutionManagerSingleton, {
JSExecutorResult,
} from "jsExecution/JSExecutionManagerSingleton";
import unescapeJS from "unescape-js";
import toposort from "toposort";
import {
DataTree,
DataTreeEntity,
DataTreeWidget,
ENTITY_TYPE,
} from "entities/DataTree/dataTreeFactory";
import * as log from "loglevel";
import equal from "fast-deep-equal/es6";
import WidgetFactory from "utils/WidgetFactory";
import { AppToaster } from "components/editorComponents/ToastComponent";
import { ToastType } from "react-toastify";
import { Action } from "entities/Action";
export const removeBindingsFromActionObject = (obj: Action) => {
const string = JSON.stringify(obj);
const withBindings = string.replace(DATA_BIND_REGEX_GLOBAL, "{{ }}");
return JSON.parse(withBindings);
};
// referencing DATA_BIND_REGEX fails for the value "{{Table1.tableData[Table1.selectedRowIndex]}}" if you run it multiple times and don't recreate
export const isDynamicValue = (value: string): boolean =>
DATA_BIND_REGEX.test(value);
//{{}}{{}}}
export function getDynamicStringSegments(dynamicString: string): string[] {
let stringSegments = [];
const indexOfDoubleParanStart = dynamicString.indexOf("{{");
if (indexOfDoubleParanStart === -1) {
return [dynamicString];
}
//{{}}{{}}}
const firstString = dynamicString.substring(0, indexOfDoubleParanStart);
firstString && stringSegments.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) {
stringSegments.push(rest.substring(0, i + 1));
rest = rest.substring(i + 1, rest.length);
if (rest) {
stringSegments = stringSegments.concat(
getDynamicStringSegments(rest),
);
break;
}
}
}
}
if (sum !== 0 && dynamicString !== "") {
return [dynamicString];
}
return stringSegments;
}
const getAllPaths = (
tree: Record<string, any>,
prefix = "",
): Record<string, true> => {
return Object.keys(tree).reduce((res: Record<string, true>, el): Record<
string,
true
> => {
if (Array.isArray(tree[el])) {
const key = `${prefix}${el}`;
return { ...res, [key]: true };
} else if (typeof tree[el] === "object" && tree[el] !== null) {
const key = `${prefix}${el}`;
return { ...res, [key]: true, ...getAllPaths(tree[el], `${key}.`) };
} else {
const key = `${prefix}${el}`;
return { ...res, [key]: true };
}
}, {});
};
export const getDynamicBindings = (
dynamicString: string,
): { stringSegments: string[]; jsSnippets: string[] } => {
// Protect against bad string parse
if (!dynamicString || !_.isString(dynamicString)) {
return { stringSegments: [], jsSnippets: [] };
}
const sanitisedString = dynamicString.trim();
// Get the {{binding}} bound values
const stringSegments = getDynamicStringSegments(sanitisedString);
// Get the "binding" path values
const paths = stringSegments.map(segment => {
const length = segment.length;
const matches = isDynamicValue(segment);
if (matches) {
return segment.substring(2, length - 2);
}
return "";
});
return { stringSegments: stringSegments, jsSnippets: paths };
};
// Paths are expected to have "{name}.{path}" signature
// Also returns any action triggers found after evaluating value
export const evaluateDynamicBoundValue = (
data: DataTree,
path: string,
callbackData?: any,
): JSExecutorResult => {
const unescapedInput = unescapeJS(path);
return JSExecutionManagerSingleton.evaluateSync(
unescapedInput,
data,
callbackData,
);
};
// For creating a final value where bindings could be in a template format
export const createDynamicValueString = (
binding: string,
subBindings: string[],
subValues: string[],
): string => {
// Replace the string with the data tree values
let finalValue = binding;
subBindings.forEach((b, i) => {
let value = subValues[i];
if (Array.isArray(value) || _.isObject(value)) {
value = JSON.stringify(value);
}
try {
if (JSON.parse(value)) {
value = value.replace(/\\([\s\S])|(")/g, "\\$1$2");
}
} catch (e) {
// do nothing
}
finalValue = finalValue.replace(b, value);
});
return finalValue;
};
export const getDynamicValue = (
dynamicBinding: string,
data: DataTree,
callBackData?: any,
includeTriggers = false,
): JSExecutorResult => {
// Get the {{binding}} bound values
const { stringSegments, jsSnippets } = getDynamicBindings(dynamicBinding);
if (stringSegments.length) {
// Get the Data Tree value of those "binding "paths
const values = jsSnippets.map((jsSnippet, index) => {
if (jsSnippet) {
const result = evaluateDynamicBoundValue(data, jsSnippet, callBackData);
if (includeTriggers) {
return result;
} else {
return { result: result.result };
}
} else {
return { result: stringSegments[index], triggers: [] };
}
});
// if it is just one binding, no need to create template string
if (stringSegments.length === 1) return values[0];
// else return a string template with bindings
const templateString = createDynamicValueString(
dynamicBinding,
stringSegments,
values.map(v => v.result),
);
return {
result: templateString,
};
}
return { result: undefined, triggers: [] };
};
export const getValidatedTree = (tree: any) => {
return Object.keys(tree).reduce((tree, entityKey: string) => {
const entity = tree[entityKey];
if (entity && entity.type) {
const parsedEntity = { ...entity };
Object.keys(entity).forEach((property: string) => {
const hasEvaluatedValue = _.has(
parsedEntity,
`evaluatedValues.${property}`,
);
const hasValidation = _.has(parsedEntity, `invalidProps.${property}`);
const isSpecialField = [
"dynamicBindings",
"dynamicTriggers",
"dynamicProperties",
"evaluatedValues",
"invalidProps",
"validationMessages",
].includes(property);
const isDynamicField =
_.has(parsedEntity, `dynamicBindings.${property}`) ||
_.has(parsedEntity, `dynamicTriggers.${property}`);
if (
!isSpecialField &&
!isDynamicField &&
(!hasValidation || !hasEvaluatedValue)
) {
const value = entity[property];
// Pass it through parse
const {
parsed,
isValid,
message,
transformed,
} = ValidationFactory.validateWidgetProperty(
entity.type,
property,
value,
entity,
tree,
);
parsedEntity[property] = parsed;
if (!hasEvaluatedValue) {
const evaluatedValue = _.isUndefined(transformed)
? value
: transformed;
_.set(parsedEntity, `evaluatedValues.${property}`, evaluatedValue);
}
const hasValidation = _.has(parsedEntity, `invalidProps.${property}`);
if (!hasValidation && !isValid) {
_.set(parsedEntity, `invalidProps.${property}`, true);
_.set(parsedEntity, `validationMessages.${property}`, message);
}
}
});
return { ...tree, [entityKey]: parsedEntity };
}
return tree;
}, tree);
};
let dependencyTreeCache: any = {};
let cachedDataTreeString = "";
export function getEvaluatedDataTree(dataTree: DataTree): DataTree {
const totalStart = performance.now();
// Create Dependencies DAG
const createDepsStart = performance.now();
const dataTreeString = JSON.stringify(dataTree);
if (!equal(dataTreeString, cachedDataTreeString)) {
cachedDataTreeString = dataTreeString;
dependencyTreeCache = createDependencyTree(dataTree);
}
const createDepsEnd = performance.now();
const {
dependencyMap,
sortedDependencies,
dependencyTree,
} = dependencyTreeCache;
// Evaluate Tree
const evaluatedTreeStart = performance.now();
const evaluatedTree = dependencySortedEvaluateDataTree(
dataTree,
dependencyMap,
sortedDependencies,
);
const evaluatedTreeEnd = performance.now();
// Set Loading Widgets
const loadingTreeStart = performance.now();
const treeWithLoading = setTreeLoading(evaluatedTree, dependencyTree);
const loadingTreeEnd = performance.now();
// Validate Widgets
const validated = getValidatedTree(treeWithLoading);
// End counting total time
const endStart = performance.now();
// Log time taken and count
const timeTaken = {
total: (endStart - totalStart).toFixed(2),
createDeps: (createDepsEnd - createDepsStart).toFixed(2),
evaluate: (evaluatedTreeEnd - evaluatedTreeStart).toFixed(2),
loading: (loadingTreeEnd - loadingTreeStart).toFixed(2),
};
log.debug("data tree evaluated");
log.debug(timeTaken);
// dataTreeCache = validated;
return validated;
}
type DynamicDependencyMap = Record<string, Array<string>>;
export const createDependencyTree = (
dataTree: DataTree,
): {
sortedDependencies: Array<string>;
dependencyTree: Array<[string, string]>;
dependencyMap: DynamicDependencyMap;
} => {
const dependencyMap: DynamicDependencyMap = {};
const allKeys = getAllPaths(dataTree);
Object.keys(dataTree).forEach(entityKey => {
const entity = dataTree[entityKey];
if (entity && "ENTITY_TYPE" in entity) {
if (entity.ENTITY_TYPE === ENTITY_TYPE.WIDGET) {
// Set default property dependency
const defaultProperties = WidgetFactory.getWidgetDefaultPropertiesMap(
entity.type,
);
Object.keys(defaultProperties).forEach(property => {
dependencyMap[`${entityKey}.${property}`] = [
`${entityKey}.${defaultProperties[property]}`,
];
});
if (entity.dynamicBindings) {
Object.keys(entity.dynamicBindings).forEach(prop => {
const { jsSnippets } = getDynamicBindings(_.get(entity, prop));
const existingDeps = dependencyMap[`${entityKey}.${prop}`] || [];
dependencyMap[`${entityKey}.${prop}`] = existingDeps.concat(
jsSnippets.filter(jsSnippet => !!jsSnippet),
);
});
}
if (entity.dynamicTriggers) {
Object.keys(entity.dynamicTriggers).forEach(prop => {
dependencyMap[`${entityKey}.${prop}`] = [];
});
}
}
if (entity.ENTITY_TYPE === ENTITY_TYPE.ACTION) {
if (entity.dynamicBindingPathList.length) {
entity.dynamicBindingPathList.forEach(prop => {
const { jsSnippets } = getDynamicBindings(_.get(entity, prop.key));
const existingDeps =
dependencyMap[`${entityKey}.${prop.key}`] || [];
dependencyMap[`${entityKey}.${prop.key}`] = existingDeps.concat(
jsSnippets.filter(jsSnippet => !!jsSnippet),
);
});
}
}
}
});
Object.keys(dependencyMap).forEach(key => {
dependencyMap[key] = _.flatten(
dependencyMap[key].map(path => calculateSubDependencies(path, allKeys)),
);
});
const dependencyTree: Array<[string, string]> = [];
Object.keys(dependencyMap).forEach((key: string) => {
if (dependencyMap[key].length) {
dependencyMap[key].forEach(dep => dependencyTree.push([key, dep]));
} else {
// Set no dependency
dependencyTree.push([key, ""]);
}
});
try {
// sort dependencies and remove empty dependencies
const sortedDependencies = toposort(dependencyTree)
.reverse()
.filter(d => !!d);
return { sortedDependencies, dependencyMap, dependencyTree };
} catch (e) {
console.error(e);
AppToaster.show({
message: e.message,
type: ToastType.ERROR,
});
return { sortedDependencies: [], dependencyMap: {}, dependencyTree: [] };
}
};
const calculateSubDependencies = (
path: string,
all: Record<string, true>,
): Array<string> => {
const subDeps: Array<string> = [];
const identifiers = path.match(/[a-zA-Z_$][a-zA-Z_$0-9.]*/g) || [path];
identifiers.forEach((identifier: string) => {
if (identifier in all) {
subDeps.push(identifier);
} else {
const subIdentifiers =
identifier.match(/[a-zA-Z_$][a-zA-Z_$0-9]*/g) || [];
let current = "";
for (let i = 0; i < subIdentifiers.length; i++) {
const key = `${current}${current ? "." : ""}${subIdentifiers[i]}`;
if (key in all) {
current = key;
} else {
break;
}
}
if (current && current.includes(".")) subDeps.push(current);
}
});
return _.uniq(subDeps);
};
export const setTreeLoading = (
dataTree: DataTree,
dependencyMap: Array<[string, string]>,
) => {
const widgets: string[] = [];
const isLoadingActions: string[] = [];
// Fetch all actions that are in loading state
Object.keys(dataTree).forEach(e => {
const entity = dataTree[e];
if (entity && "ENTITY_TYPE" in entity) {
if (entity.ENTITY_TYPE === ENTITY_TYPE.WIDGET) {
widgets.push(e);
} else if (
entity.ENTITY_TYPE === ENTITY_TYPE.ACTION &&
entity.isLoading
) {
isLoadingActions.push(e);
}
}
});
// get all widget dependencies of those actions
isLoadingActions
.reduce(
(allEntities: string[], curr) =>
allEntities.concat(getEntityDependencies(dependencyMap, curr, widgets)),
[],
)
// set loading to true for those widgets
.forEach(w => {
const entity = dataTree[w] as DataTreeWidget;
entity.isLoading = true;
});
return dataTree;
};
export const getEntityDependencies = (
dependencyMap: Array<[string, string]>,
entity: string,
entities: string[],
): Array<string> => {
const entityDeps: Record<string, string[]> = dependencyMap
.map(d => [d[1].split(".")[0], d[0].split(".")[0]])
.filter(d => d[0] !== d[1])
.reduce((deps: Record<string, string[]>, dep) => {
const key: string = dep[0];
const value: string = dep[1];
return {
...deps,
[key]: deps[key] ? deps[key].concat(value) : [value],
};
}, {});
if (entity in entityDeps) {
const recFind = (
keys: Array<string>,
deps: Record<string, string[]>,
): Array<string> => {
let allDeps: string[] = [];
keys
.filter(k => entities.includes(k))
.forEach(e => {
allDeps = allDeps.concat([e]);
if (e in deps) {
allDeps = allDeps.concat([...recFind(deps[e], deps)]);
}
});
return allDeps;
};
return recFind(entityDeps[entity], entityDeps);
}
return [];
};
const dynamicPropValueCache: Map<
string,
{
unEvaluated: any;
evaluated: any;
}
> = new Map();
const parsedValueCache: Map<
string,
{
value: any;
version: number;
}
> = new Map();
const getDynamicPropValueCache = (propertyPath: string) =>
dynamicPropValueCache.get(propertyPath);
const getParsedValueCache = (propertyPath: string) =>
parsedValueCache.get(propertyPath) || {
value: undefined,
version: 0,
};
export const clearPropertyCache = (propertyPath: string) =>
parsedValueCache.delete(propertyPath);
const dependencyCache: Map<string, any[]> = new Map();
export const clearCaches = () => {
dynamicPropValueCache.clear();
dependencyCache.clear();
parsedValueCache.clear();
};
function getCurrentDependencyValues(
propertyDependencies: Array<string>,
currentTree: DataTree,
currentPropertyPath: string,
): Array<string> {
return propertyDependencies
? propertyDependencies
.map((path: string) => {
//*** Remove current path from data tree because cached value contains evaluated version while this contains unevaluated version */
const cleanDataTree = _.omit(currentTree, [currentPropertyPath]);
return _.get(cleanDataTree, path);
})
.filter((data: any) => {
return data !== undefined;
})
: [];
}
function evaluateDynamicProperty(
propertyPath: string,
currentTree: DataTree,
unEvalPropertyValue: any,
currentDependencyValues: Array<string>,
cachedDependencyValues?: Array<string>,
): any {
const cacheObj = getDynamicPropValueCache(propertyPath);
const isCacheHit =
cacheObj &&
equal(cacheObj.unEvaluated, unEvalPropertyValue) &&
cachedDependencyValues !== undefined &&
equal(currentDependencyValues, cachedDependencyValues);
if (isCacheHit && cacheObj) {
return cacheObj.evaluated;
} else {
log.debug("eval " + propertyPath);
const dynamicResult = getDynamicValue(unEvalPropertyValue, currentTree);
dynamicPropValueCache.set(propertyPath, {
evaluated: dynamicResult.result,
unEvaluated: unEvalPropertyValue,
});
dependencyCache.set(propertyPath, currentDependencyValues);
return dynamicResult.result;
}
}
function validateAndParseWidgetProperty(
propertyPath: string,
widget: DataTreeWidget,
currentTree: DataTree,
evalPropertyValue: any,
unEvalPropertyValue: string,
currentDependencyValues: Array<string>,
cachedDependencyValues?: Array<string>,
): any {
const propertyName = propertyPath.split(".")[1];
let valueToValidate = evalPropertyValue;
if (widget.dynamicTriggers && propertyName in widget.dynamicTriggers) {
const { triggers } = getDynamicValue(
unEvalPropertyValue,
currentTree,
undefined,
true,
);
valueToValidate = triggers;
}
const {
parsed,
isValid,
message,
transformed,
} = ValidationFactory.validateWidgetProperty(
widget.type,
propertyName,
valueToValidate,
widget,
currentTree,
);
const evaluatedValue = _.isUndefined(transformed)
? evalPropertyValue
: transformed;
_.set(widget, `evaluatedValues.${propertyName}`, evaluatedValue);
if (!isValid) {
_.set(widget, `invalidProps.${propertyName}`, true);
_.set(widget, `validationMessages.${propertyName}`, message);
}
if (widget.dynamicTriggers && propertyName in widget.dynamicTriggers) {
return unEvalPropertyValue;
} else {
const parsedCache = getParsedValueCache(propertyPath);
if (
!equal(parsedCache.value, parsed) ||
(cachedDependencyValues !== undefined &&
!equal(currentDependencyValues, cachedDependencyValues))
) {
parsedValueCache.set(propertyPath, {
value: parsed,
version: Date.now(),
});
}
return parsed;
}
}
function isWidget(entity: DataTreeEntity): boolean {
return "ENTITY_TYPE" in entity && entity.ENTITY_TYPE === ENTITY_TYPE.WIDGET;
}
export function dependencySortedEvaluateDataTree(
dataTree: DataTree,
dependencyMap: DynamicDependencyMap,
sortedDependencies: Array<string>,
): DataTree {
const tree = _.cloneDeep(dataTree);
try {
return sortedDependencies.reduce(
(currentTree: DataTree, propertyPath: string) => {
const entityName = propertyPath.split(".")[0];
const entity: DataTreeEntity = currentTree[entityName];
const unEvalPropertyValue = _.get(currentTree as any, propertyPath);
let evalPropertyValue;
const propertyDependencies = dependencyMap[propertyPath];
const currentDependencyValues = getCurrentDependencyValues(
propertyDependencies,
currentTree,
propertyPath,
);
const cachedDependencyValues = dependencyCache.get(propertyPath);
const requiresEval = isDynamicValue(unEvalPropertyValue);
if (requiresEval) {
try {
evalPropertyValue = evaluateDynamicProperty(
propertyPath,
currentTree,
unEvalPropertyValue,
currentDependencyValues,
cachedDependencyValues,
);
} catch (e) {
console.error(e);
evalPropertyValue = undefined;
}
} else {
evalPropertyValue = unEvalPropertyValue;
// If we have stored any previous dependency cache, clear it
// since it is no longer a binding
if (cachedDependencyValues && cachedDependencyValues.length) {
dependencyCache.set(propertyPath, []);
}
}
if (isWidget(entity)) {
const widgetEntity: DataTreeWidget = entity as DataTreeWidget;
const propertyName = propertyPath.split(".")[1];
if (propertyName) {
let parsedValue = validateAndParseWidgetProperty(
propertyPath,
widgetEntity,
currentTree,
evalPropertyValue,
unEvalPropertyValue,
currentDependencyValues,
cachedDependencyValues,
);
const defaultPropertyMap = WidgetFactory.getWidgetDefaultPropertiesMap(
widgetEntity.type,
);
const hasDefaultProperty = propertyName in defaultPropertyMap;
if (hasDefaultProperty) {
const defaultProperty = defaultPropertyMap[propertyName];
parsedValue = overwriteDefaultDependentProps(
defaultProperty,
parsedValue,
propertyPath,
widgetEntity,
);
}
return _.set(currentTree, propertyPath, parsedValue);
}
return _.set(currentTree, propertyPath, evalPropertyValue);
} else {
return _.set(currentTree, propertyPath, evalPropertyValue);
}
},
tree,
);
} catch (e) {
console.error(e);
return tree;
}
}
const overwriteDefaultDependentProps = (
defaultProperty: string,
propertyValue: any,
propertyPath: string,
entity: DataTreeWidget,
) => {
const defaultPropertyCache = getParsedValueCache(
`${entity.widgetName}.${defaultProperty}`,
);
const propertyCache = getParsedValueCache(propertyPath);
if (
propertyValue === undefined ||
propertyCache.version < defaultPropertyCache.version
) {
return defaultPropertyCache.value;
}
return propertyValue;
};
/*
Need to evaluated values
Need to validate widget values
Need to replace with default values
*/