import { Injectable } from '@angular/core'; import { ReferenceFieldsUI } from '@core/typings/ui/reference-fields.typing'; import { FormDefinitionForUi, FormioAnswerValues } from '@features/configure-forms/form.typing'; import { EvaluationContext, EvaluationFn, EvaluationType } from '@features/formio/form-logic-evaluator.typing'; import { ReferenceFieldsService } from '@features/reference-fields/services/reference-fields.service'; import { LogService } from '@yourcause/common/logging'; import * as _ from 'lodash'; import { keys, values } from 'lodash'; import moment from 'moment'; import { ComponentHelperService } from '../component-helper/component-helper.service'; import { FormLogicService } from '../form-logic/form-logic.service'; @Injectable({ providedIn: 'root' }) export class FormLogicEvaluatorService { /* make sure js isn't executed more times than the threshold within the duration e.g. 50 executions in 1000 ms */ readonly jsExecutionDuration = 1000; readonly jsExecutionThreshold = 150; constructor ( private logger: LogService, private formLogicService: FormLogicService, private referenceFieldService: ReferenceFieldsService, private componentHelper: ComponentHelperService ) { } /** * Runs arbitrary javascript and returns a variable * * @param code The logic to be evaluated * @param args The arguments to the logic * @param returnVar The variable that will be returned * @param returnVarDefault The default value for the variable being returned */ evaluateFunction ( code: string, args: A, returnVar: string, returnVarDefault: T ): T { const innerFunction = `var ${returnVar} = ${JSON.stringify(returnVarDefault)};${code};return ${returnVar};`; const argValues = values(args); const argNames = keys(args); const fn = new Function(...argNames, innerFunction); return fn(...argValues); } buildUpDefaultArgs (fnArgs: EvaluationContext): EvaluationContext { return Object.assign(fnArgs, { _, moment }); } evaluateShow (code: string, fnArgs: EvaluationContext) { const args = this.buildUpDefaultArgs(fnArgs); return this.evaluateFunction(code, args, 'show', true); } evaluateCalculateValue (code: string, fnArgs: EvaluationContext) { const args = this.buildUpDefaultArgs(fnArgs); return this.evaluateFunction(code, args, 'value', ''); } evaluateValidity (code: string, fnArgs: EvaluationContext) { const args = this.buildUpDefaultArgs(fnArgs); return this.evaluateFunction(code, args, 'valid', ''); } wrapFunction (type: EvaluationType, fn: (data: Record) => any) { const returnFn = ((data: Record) => { try { return fn(data); } catch (e) { this.logger.error(e, { subMessage: 'Error executing custom logic' }); } }) as EvaluationFn; returnFn.type = type; return returnFn; } getFunctionCalls (formDefinitions: FormDefinitionForUi[]): EvaluationFn[] { // eslint-disable-next-line @typescript-eslint/ban-types const executions: EvaluationFn[] = []; formDefinitions.forEach(formDefinition => { this.componentHelper.eachComponent(formDefinition.components, (component) => { const getFnContext = (data: Record) => ({ data, submission: data, form: formDefinition, component, instance: component, value: data[component.key], input: data[component.key] }); if (component.validate?.custom) { executions.push(this.wrapFunction(EvaluationType.Validation, ( data: Record ) => { const result = this.evaluateValidity(component.validate.custom, getFnContext(data)); if (result && result !== true) { component.validate.validationResult = result; } else { component.validate.validationResult = null; } })); } if (component.customConditional) { executions.push(this.wrapFunction(EvaluationType.Visibility, ( data: Record ) => { const show = this.evaluateShow(component.customConditional, getFnContext(data)); component.isHidden = !show; })); } if (component.calculateValue) { let previousCalculatedValue: FormioAnswerValues; executions.push(this.wrapFunction(EvaluationType.CalculatedValue, ( data: Record ) => { const formResponse = data[component.key]; const isFirstRun = previousCalculatedValue === undefined; const runResult = this.evaluateCalculateValue( component.calculateValue, getFnContext(data) ); if (isFirstRun) { previousCalculatedValue = runResult; } const { currentValue, usedRunResult } = this.componentHelper.determineCorrectCalculatedValueResult( isFirstRun, runResult, previousCalculatedValue, formResponse, component.type, component.allowCalculateOverride ); if (usedRunResult) { previousCalculatedValue = currentValue; } let calculatedValue = this.attemptConvertToType(currentValue, component.type); calculatedValue = this.formLogicService.convertCalculatedValueToCurrencyVal( calculatedValue, component.type, component.value ) as any; this.formLogicService.setValueForComp(component, calculatedValue); })); } }, true); }); return executions; } /** * Execute the Javascript form logic * * @param fns: Functions to execute * @param isInit: Is init? * @param isReadOnly: Is the form read only? * @param data: the data to provide the functions * @returns whether we need to apply component logic results */ executeJSFunctions ( fns: EvaluationFn[], isInit: boolean, isReadOnly: boolean, data: Record ) { let numberOfExecutions = 0; const filteredFuncs = this.getFilteredFuncs(fns, isInit, isReadOnly); if (filteredFuncs.length > 0) { filteredFuncs.forEach((fn) => { ++numberOfExecutions; fn(data); }); return { applyResults: true, numberOfExecutions }; } return { applyResults: false, numberOfExecutions }; } /** * Filters the functions to the ones we need to fire based on scenario * * @param fns: Functions to filter * @param isInit: is init? * @param isReadOnly: is read only? * @returns the filtered functions that are applicable */ getFilteredFuncs ( fns: EvaluationFn[], isInit: boolean, isReadOnly: boolean ) { return fns.filter((fn) => { if (isReadOnly) { // When read only, we only want to execute visibility const isVisibility = fn.type === EvaluationType.Visibility; return isVisibility; } else if (isInit) { const isNonValidation = fn.type !== EvaluationType.Validation; // If editable and init, execute everything except validation return isNonValidation; } return true; }); } private attemptConvertToType ( calculatedValue: unknown, compType: string ): string|number { const field = this.referenceFieldService.getReferenceFieldFromCompType(compType); const isNumber = compType === 'amountRequested' || compType === 'reviewerRecommendedFundingAmount' || [ ReferenceFieldsUI.ReferenceFieldTypes.Number, ReferenceFieldsUI.ReferenceFieldTypes.Currency ].includes(field?.type); if (isNumber) { if ( !(_.isNull(calculatedValue) || _.isUndefined(calculatedValue)) && !_.isNumber(calculatedValue) ) { calculatedValue = +calculatedValue; } } else { calculatedValue = calculatedValue?.toString(); } return calculatedValue as string|number; } }