import { DependencyMap, EVAL_ERROR_PATH, EvaluationError, getEvalErrorPath, getEvalValuePath, isChildPropertyPath, isDynamicValue, PropertyEvaluationErrorType, } from "utils/DynamicBindingUtils"; import { validate } from "./validations"; import { Diff } from "deep-diff"; import { DataTree, DataTreeAction, DataTreeAppsmith, DataTreeEntity, DataTreeWidget, ENTITY_TYPE, DataTreeJSAction, } from "entities/DataTree/dataTreeFactory"; import _ from "lodash"; import { WidgetTypeConfigMap } from "utils/WidgetFactory"; import { ValidationConfig } from "constants/PropertyControlConstants"; import { Severity } from "entities/AppsmithConsole"; import { JSCollection, Variable } from "entities/JSCollection"; import evaluate from "workers/evaluate"; // Dropdown1.options[1].value -> Dropdown1.options[1] // Dropdown1.options[1] -> Dropdown1.options // Dropdown1.options -> Dropdown1 export const IMMEDIATE_PARENT_REGEX = /^(.*)(\..*|\[.*\])$/; export enum DataTreeDiffEvent { NEW = "NEW", DELETE = "DELETE", EDIT = "EDIT", NOOP = "NOOP", } export type DataTreeDiff = { payload: { propertyPath: string; value?: string; }; event: DataTreeDiffEvent; }; export class CrashingError extends Error {} export const convertPathToString = (arrPath: Array) => { let string = ""; arrPath.forEach((segment) => { if (isInt(segment)) { string = string + "[" + segment + "]"; } else { if (string.length !== 0) { string = string + "."; } string = string + segment; } }); return string; }; // Todo: improve the logic here // Right now NaN, Infinity, floats, everything works function isInt(val: string | number): boolean { return Number.isInteger(val) || (_.isString(val) && /^\d+$/.test(val)); } // Removes the entity name from the property path export function getEntityNameAndPropertyPath( fullPath: string, ): { entityName: string; propertyPath: string; } { const indexOfFirstDot = fullPath.indexOf("."); if (indexOfFirstDot === -1) { // No dot was found so path is the entity name itself return { entityName: fullPath, propertyPath: "", }; } const entityName = fullPath.substring(0, indexOfFirstDot); const propertyPath = fullPath.substring(indexOfFirstDot + 1); return { entityName, propertyPath }; } export const translateDiffEventToDataTreeDiffEvent = ( difference: Diff, unEvalDataTree: DataTree, ): DataTreeDiff | DataTreeDiff[] => { let result: DataTreeDiff | DataTreeDiff[] = { payload: { propertyPath: "", value: "", }, event: DataTreeDiffEvent.NOOP, }; if (!difference.path) { return result; } const propertyPath = convertPathToString(difference.path); const { entityName } = getEntityNameAndPropertyPath(propertyPath); const entity = unEvalDataTree[entityName]; const isJsAction = isJSAction(entity); switch (difference.kind) { case "N": { result.event = DataTreeDiffEvent.NEW; result.payload = { propertyPath, }; break; } case "D": { result.event = DataTreeDiffEvent.DELETE; result.payload = { propertyPath }; break; } case "E": { let rhsChange, lhsChange; if (isJsAction) { rhsChange = typeof difference.rhs === "string"; lhsChange = typeof difference.lhs === "string"; } else { rhsChange = typeof difference.rhs === "string" && isDynamicValue(difference.rhs); lhsChange = typeof difference.lhs === "string" && isDynamicValue(difference.lhs); } if (rhsChange || lhsChange) { result.event = DataTreeDiffEvent.EDIT; result.payload = { propertyPath, value: difference.rhs, }; } else if (difference.lhs === undefined || difference.rhs === undefined) { // Handle static value changes that change structure that can lead to // old bindings being eligible if (difference.lhs === undefined && isTrueObject(difference.rhs)) { result.event = DataTreeDiffEvent.NEW; result.payload = { propertyPath }; } if (difference.rhs === undefined && isTrueObject(difference.lhs)) { result.event = DataTreeDiffEvent.DELETE; result.payload = { propertyPath }; } } else if ( isTrueObject(difference.lhs) && !isTrueObject(difference.rhs) ) { // This will happen for static value changes where a property went // from being an object to any other type like string or number // in such a case we want to delete all nested paths of the // original lhs object result = Object.keys(difference.lhs).map((diffKey) => { const path = `${propertyPath}.${diffKey}`; return { event: DataTreeDiffEvent.DELETE, payload: { propertyPath: path, }, }; }); } else if ( !isTrueObject(difference.lhs) && isTrueObject(difference.rhs) ) { // This will happen for static value changes where a property went // from being any other type like string or number to an object // in such a case we want to add all nested paths of the // new rhs object result = Object.keys(difference.rhs).map((diffKey) => { const path = `${propertyPath}.${diffKey}`; return { event: DataTreeDiffEvent.NEW, payload: { propertyPath: path, }, }; }); } break; } case "A": { return translateDiffEventToDataTreeDiffEvent( { ...difference.item, path: [...difference.path, difference.index], }, unEvalDataTree, ); } default: { break; } } return result; }; /* Table1.selectedRow Table1.selectedRow.email: ["Input1.defaultText"] */ export const addDependantsOfNestedPropertyPaths = ( parentPaths: Array, inverseMap: DependencyMap, ): Set => { const withNestedPaths: Set = new Set(); const dependantNodes = Object.keys(inverseMap); parentPaths.forEach((propertyPath) => { withNestedPaths.add(propertyPath); dependantNodes .filter((dependantNodePath) => isChildPropertyPath(propertyPath, dependantNodePath), ) .forEach((dependantNodePath) => { inverseMap[dependantNodePath].forEach((path) => { withNestedPaths.add(path); }); }); }); return withNestedPaths; }; export function isWidget(entity: DataTreeEntity): entity is DataTreeWidget { return ( typeof entity === "object" && "ENTITY_TYPE" in entity && entity.ENTITY_TYPE === ENTITY_TYPE.WIDGET ); } export function isAction(entity: DataTreeEntity): entity is DataTreeAction { return ( typeof entity === "object" && "ENTITY_TYPE" in entity && entity.ENTITY_TYPE === ENTITY_TYPE.ACTION ); } export function isAppsmithEntity( entity: DataTreeEntity, ): entity is DataTreeAppsmith { return ( typeof entity === "object" && "ENTITY_TYPE" in entity && entity.ENTITY_TYPE === ENTITY_TYPE.APPSMITH ); } export function isJSAction(entity: DataTreeEntity): entity is DataTreeJSAction { return ( typeof entity === "object" && "ENTITY_TYPE" in entity && entity.ENTITY_TYPE === ENTITY_TYPE.JSACTION ); } // We need to remove functions from data tree to avoid any unexpected identifier while JSON parsing // Check issue https://github.com/appsmithorg/appsmith/issues/719 export const removeFunctions = (value: any) => { if (_.isFunction(value)) { return "Function call"; } else if (_.isObject(value)) { return JSON.parse( JSON.stringify(value, (_, v) => typeof v === "bigint" ? v.toString() : v, ), ); } else { return value; } }; export const makeParentsDependOnChildren = ( depMap: DependencyMap, ): DependencyMap => { //return depMap; // Make all parents depend on child Object.keys(depMap).forEach((key) => { depMap = makeParentsDependOnChild(depMap, key); depMap[key].forEach((path) => { depMap = makeParentsDependOnChild(depMap, path); }); }); return depMap; }; export const makeParentsDependOnChild = ( depMap: DependencyMap, child: string, ): DependencyMap => { const result: DependencyMap = depMap; let curKey = child; let matches: Array | null; // Note: The `=` is intentional // Stops looping when match is null while ((matches = curKey.match(IMMEDIATE_PARENT_REGEX)) !== null) { const parentKey = matches[1]; // Todo: switch everything to set. const existing = new Set(result[parentKey] || []); existing.add(curKey); result[parentKey] = Array.from(existing); curKey = parentKey; } return result; }; // The idea is to find the immediate parents of the property paths // e.g. For Table1.selectedRow.email, the parent is Table1.selectedRow export const getImmediateParentsOfPropertyPaths = ( propertyPaths: Array, ): Array => { // Use a set to ensure that we dont have duplicates const parents: Set = new Set(); propertyPaths.forEach((path) => { const matches = path.match(IMMEDIATE_PARENT_REGEX); if (matches !== null) { parents.add(matches[1]); } }); return Array.from(parents); }; export function validateWidgetProperty( config: ValidationConfig, value: unknown, props: Record, ) { if (!config) { return { isValid: true, parsed: value, }; } return validate(config, value, props); } export function getValidatedTree(tree: DataTree) { return Object.keys(tree).reduce((tree, entityKey: string) => { const entity = tree[entityKey] as DataTreeWidget; if (!isWidget(entity)) { return tree; } const parsedEntity = { ...entity }; Object.entries(entity.validationPaths).forEach(([property, validation]) => { const value = _.get(entity, property); // Pass it through parse const { isValid, messages, parsed, transformed } = validateWidgetProperty( validation, value, entity, ); _.set(parsedEntity, property, parsed); const evaluatedValue = isValid ? parsed : _.isUndefined(transformed) ? value : transformed; const safeEvaluatedValue = removeFunctions(evaluatedValue); _.set( parsedEntity, getEvalValuePath(`${entityKey}.${property}`, false), safeEvaluatedValue, ); if (!isValid) { const evalErrors: EvaluationError[] = messages?.map((message) => ({ errorType: PropertyEvaluationErrorType.VALIDATION, errorMessage: message, severity: Severity.ERROR, raw: value, })) ?? []; addErrorToEntityProperty( evalErrors, tree, getEvalErrorPath(`${entityKey}.${property}`, false), ); } }); return { ...tree, [entityKey]: parsedEntity }; }, tree); } export const getAllPaths = ( records: any, curKey = "", result: Record = {}, ): Record => { // Add the key if it exists if (curKey) result[curKey] = true; if (Array.isArray(records)) { for (let i = 0; i < records.length; i++) { const tempKey = curKey ? `${curKey}[${i}]` : `${i}`; getAllPaths(records[i], tempKey, result); } } else if (typeof records === "object") { for (const key in records) { const tempKey = curKey ? `${curKey}.${key}` : `${key}`; getAllPaths(records[key], tempKey, result); } } return result; }; export const trimDependantChangePaths = ( changePaths: Set, dependencyMap: DependencyMap, ): Array => { const trimmedPaths = []; for (const path of changePaths) { let foundADependant = false; if (path in dependencyMap) { const dependants = dependencyMap[path]; for (const dependantPath of dependants) { if (changePaths.has(dependantPath)) { foundADependant = true; break; } } } if (!foundADependant) { trimmedPaths.push(path); } } return trimmedPaths; }; export function getSafeToRenderDataTree( tree: DataTree, widgetTypeConfigMap: WidgetTypeConfigMap, ) { return Object.keys(tree).reduce((tree, entityKey: string) => { const entity = tree[entityKey] as DataTreeWidget; if (!isWidget(entity)) { return tree; } const safeToRenderEntity = { ...entity }; // Set user input values to their parsed values Object.entries(entity.validationPaths).forEach(([property, validation]) => { const value = _.get(entity, property); // Pass it through parse const { parsed } = validateWidgetProperty(validation, value, entity); _.set(safeToRenderEntity, property, parsed); }); // Set derived values to undefined or else they would go as bindings Object.keys(widgetTypeConfigMap[entity.type].derivedProperties).forEach( (property) => { _.set(safeToRenderEntity, property, undefined); }, ); return { ...tree, [entityKey]: safeToRenderEntity }; }, tree); } export const STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/gm; export const ARGUMENT_NAMES = /([^\s,]+)/g; export function getParams(func: any) { const fnStr = func.toString().replace(STRIP_COMMENTS, ""); const args: Array = []; let result = fnStr .slice(fnStr.indexOf("(") + 1, fnStr.indexOf(")")) .match(ARGUMENT_NAMES); if (result === null) result = []; if (result && result.length) { result.forEach((arg: string) => { const element = arg.split("="); args.push({ name: element[0], value: element[1], }); }); } return args; } export const addErrorToEntityProperty = ( errors: EvaluationError[], dataTree: DataTree, path: string, ) => { const { entityName, propertyPath } = getEntityNameAndPropertyPath(path); const logBlackList = _.get(dataTree, `${entityName}.logBlackList`, {}); if (propertyPath && !(propertyPath in logBlackList)) { const existingErrors = _.get( dataTree, `${entityName}.${EVAL_ERROR_PATH}['${propertyPath}']`, [], ) as EvaluationError[]; _.set( dataTree, `${entityName}.${EVAL_ERROR_PATH}['${propertyPath}']`, existingErrors.concat(errors), ); } return dataTree; }; // For the times when you need to know if something truly an object like { a: 1, b: 2} // typeof, lodash.isObject and others will return false positives for things like array, null, etc export const isTrueObject = ( item: unknown, ): item is Record => { return Object.prototype.toString.call(item) === "[object Object]"; }; export const isDynamicLeaf = (unEvalTree: DataTree, propertyPath: string) => { const [entityName, ...propPathEls] = _.toPath(propertyPath); // Framework feature: Top level items are never leaves if (entityName === propertyPath) return false; // Ignore if this was a delete op if (!(entityName in unEvalTree)) return false; const entity = unEvalTree[entityName]; if (!isAction(entity) && !isWidget(entity) && !isJSAction(entity)) return false; const relativePropertyPath = convertPathToString(propPathEls); return ( relativePropertyPath in entity.bindingPaths || (isWidget(entity) && relativePropertyPath in entity.triggerPaths) ); }; /* after every update get js object body to parse into actions and variables */ export const parseJSCollection = ( body: string, jsCollection: JSCollection, evalTree: DataTree, ): Record => { const regex = new RegExp(/^export default[\s]*?({[\s\S]*?})/); const correctFormat = regex.test(body); if (correctFormat) { const toBeParsedBody = body.replace(/export default/g, ""); const { errors, result } = evaluate( toBeParsedBody, evalTree, {}, undefined, true, ); const errorsList = errors && errors.length ? errors : []; _.set(evalTree, `${jsCollection.name}.${EVAL_ERROR_PATH}.body`, errorsList); const parsedLength = Object.keys(result).length; const actions = []; const variables = []; if (parsedLength > 0) { for (const key in result) { if (result.hasOwnProperty(key)) { if (typeof result[key] === "function") { const value = result[key]; const params = getParams(value); actions.push({ name: key, body: result[key].toString(), arguments: params, }); } else { variables.push({ name: key, value: result[key], }); } } } } return { evalTree, result: { actions: actions, variables: variables, }, }; } else { const errors = [ { errorType: PropertyEvaluationErrorType.PARSE, raw: "", severity: Severity.ERROR, errorMessage: "Start object with export default", }, ]; _.set(evalTree, `${jsCollection.name}.${EVAL_ERROR_PATH}.body`, errors); return { evalTree, }; } };