From 1a17fe7623681a31a3de216597c27de97e73b7b7 Mon Sep 17 00:00:00 2001 From: Brendan J Bond Date: Sun, 20 Apr 2025 09:46:35 -0500 Subject: [PATCH] This commit enables upstream consumers of the Evaluator class have a little more flexibility when extending evaluation behavior by converting the class from an entirely static class to a regular ES6 class. It also encapsulates repeated evaluation code (normalizing context, e.g.) into two convenience functions for evaluation and interpolation. --- Changelog.md | 153 ++++------------ config/webpack.config.js | 1 - config/webpack.prod.js | 1 - src/experimental/core.ts | 6 +- src/index.ts | 1 - src/modules/index.ts | 2 - src/modules/jsonlogic/index.ts | 82 --------- src/process/calculation/index.ts | 22 ++- .../clearHidden/__tests__/clearHidden.test.ts | 8 +- src/process/clearHidden/index.ts | 10 +- src/process/defaultValue/index.ts | 16 +- src/process/fetch/index.ts | 19 +- .../rules/validateAvailableItems.ts | 6 +- .../validation/rules/validateCustom.ts | 37 ++-- src/process/validation/rules/validateJson.ts | 20 +-- src/types/PassedComponentInstance.ts | 5 +- src/types/process/ProcessConfig.ts | 5 +- src/utils/Evaluator.ts | 95 +++++----- src/utils/conditions.ts | 15 +- src/utils/formUtil/index.ts | 27 +-- src/utils/i18n.ts | 167 ++++++++++++++++++ src/utils/index.ts | 5 +- .../jsonlogic/__tests__/operators.test.ts | 2 +- .../jsonLogic.ts => utils/jsonlogic/index.ts} | 6 +- src/{modules => utils}/jsonlogic/operators.ts | 2 +- src/utils/logic.ts | 9 +- src/utils/translations/en.ts | 26 +++ src/utils/utils.ts | 63 ++++++- 28 files changed, 444 insertions(+), 367 deletions(-) delete mode 100644 src/modules/index.ts delete mode 100644 src/modules/jsonlogic/index.ts create mode 100644 src/utils/i18n.ts rename src/{modules => utils}/jsonlogic/__tests__/operators.test.ts (98%) rename src/{modules/jsonlogic/jsonLogic.ts => utils/jsonlogic/index.ts} (80%) rename src/{modules => utils}/jsonlogic/operators.ts (99%) create mode 100644 src/utils/translations/en.ts diff --git a/Changelog.md b/Changelog.md index 545aafa3..13ba58e0 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,7 +1,40 @@ -## [Unreleased: 2.4.0-rc.1] - -### Changed - +## [Unreleased: 2.5.0-rc.1] +### Changed +- FIO-8228: Expanding the types for Project Roles and Access Information +- FIO-8544: Replace async callbacks with async/await +- FIO-9942: Fix issue with disabling evaluations +- FIO-9668: Fix custom error messages are not highlighted +- update exported evaluator to be 'extendable' version +- FIO-9776: Excluded Address2 field from required validation +- FIO-9642: enhance error information +- FIO-8409: added serverOverride processor and tests +- FIO-8118: removed datetime value from submission if null is submitted +- FIO-8117 removed survey from data if value is falsy +- FIO-8119: remove tags from data if value is null +- Added .idea to gitignore for webstorm users +- FIO-9357 fixed calculation based on DataSource component + +## 2.4.1 +### Changed +- FIO-9737: add deprecated tag to the unwind method +- FIO-9908: fixed an issue where conditional setting with "show" set as a string does not work well + +## 2.4.0 +### Changed +- FIO-9934 fixed appearing extra validation messages +- FIO-9874: fixed an issue where operands disappear +- Update dompurify@3.2.4 +- FIO-9796: Fixed issue where the conditions from a previous run may be in the wrong state for conditionally hidden. +- Hotfix/fix type aliases +- FIO-9649: update componentMatches fn to not omit layout components; add tests +- FIO-9668: Fix custom error messages are not highlighted +- FIO-9508: includeAll flag now works with nested components +- FIO-9511: fixed day component min/max validation message +- FIO-9467: Fix rendering table component in wizard +- FIO-9465: fix conditionals path for panel component +- FIO-9357 fixed calculation based on DataSource component +- FIO-9266/FIO-9267/FIO-9268: Fixes an issue where nested form validation will be skipped if parent form submits empty data +- FIO-9159: add intentionallyHidden ephemeral state and breaking change to clearOnHide behavior - Regression | Nested Form | Components in Nested forms should not validate hidden components without Validate When Hidden = true - FIO-8347: Added ability to skip mask validation - FIO-8273 fixed advanced logic for data components @@ -17,153 +50,41 @@ ### Changed - Official Release - -## 2.3.0-rc.23 - -### Changed - - FIO-9021: Fixed eachComponentData iteration for nested forms - -## 2.3.0-rc.22 - -### Changed - - FIO-9344 fixed require validation for day component - FIO-9329: fix issue where validateWhenHidden now validates hidden and conditionally hidden components - -## 2.3.0-rc.21 - -### Changed - - FIO-9280 updated validation of value property - FIO-9299: ensure eachComponent does not mutate a component's path - -## 2.3.0-rc.20 - -### Changed - - FIO-9308: Fixed the paths with nested forms by ensuring we are always dealing with the absolute paths with clearOnHide, conditions, filters, and validations - -## 2.3.0-rc.19 - -### Changed - - FIO-9255: fixed an issue where nested forms lose data after submission if some parent has conditional components - -## 2.3.0-rc.18 - -### Changed - - FIO-9261: fixed an issue where empty multiple value for url and datetime causes validation errors - -## 2.3.0-rc.17 - -### Changed - - FIO-9201: Fix DataTable in quick inline embed issues - -## 2.3.0-rc.16 - -### Changed - - FIO-9201: Fix DataTable in quick inline embed issues - -## 2.3.0-rc.15 - -### Changed - - FIO-9244: fixed an issue where Radio component with Allow only available values checked does not submit - -## 2.3.0-rc.14 - -### Changed - - FIO-9189: fixed an issue where data is lost after submission for the conditionally visible field when the condition is based on select resource - FIO-9219: condition is not equal to based on select box - -## 2.3.0-rc.13 - -### Changed - - FIO-9186: fixed an issue where front-end validation is skipped for the components inside layout component inside editGrid - FIO-8632: Fixes an issue where required validation is not triggered for multiple value components like Select if it has no values added - -## 2.3.0-rc.12 - -### Changed - - FIO-9086: use for validation only dataFormat (data storage format) - FIO-9202: fixed an issue where the data for the component inside fieldset insdie wizard is lost after submission - FIO-9220: remove hiddenChildren - -### Changed - -## 2.3.0-rc.11 - -### Changed - - FIO-9160: added support of different condition formats for selectboxes - -## 2.3.0-rc.10 - -### Changed - - FIO-9143 fixed getValidationFormat error - -## 2.3.0-rc.9 - -### Changed - - FIO-8731: Update fix to nested hidden components - FIO-9002: fix issue with conditionally hidden duplicate nested form paths - -## 2.3.0-rc.8 - -### Changed - - FIO-8723: Clear values from submission for hidden comp with clearOnHide flag - FIO-8954: added Allow only available values validation for Data Source Type = URL - FIO-9085: Fix address submission logic - -## 2.3.0-rc.7 - -### Changed - - FIO-9059: fixed an issue where the string type returns for textarea with json type - -## 2.3.0-rc.6 - -### Changed - - FIO-9033 tagpad data is not saved - -## 2.3.0-rc.5 - -### Changed - - FIO-9085: Fix components data removed from submission when conditional set for Address component value - FIO-8414: Fix required validation not working in Data Grid - -## 2.3.0-rc.4 - -### Changed - - FIO-8986 fixed nornalization for day with default value and hidden fields - FIO-9055: separate rowPath from componentPath in getComponentActualValue fn - -## 2.3.0-rc.3 - -### Changed - - FIO-8986 fixed validation for Day component with two hidden fields - FIO-8798: update normalization for day component - FIO-8626: Updated conditionally hidden logic - Increment minor version - -## 2.3.0-rc.1 - -### Changed - - updated thresholds to current values - FIO-8450: Fix custom error message for unique validation - FIO-8598 fixed normalization of radio component values depending on storage type diff --git a/config/webpack.config.js b/config/webpack.config.js index ef306752..d007e8ca 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -5,7 +5,6 @@ module.exports = { mode: 'development', entry: { 'formio.core.js': './lib/index.js', - 'formio.modules.js': './lib/modules/index.js', 'formio.js': './lib/sdk/index.js', 'formio.utils.js': './lib/utils/index.js', 'formio.process.js': './lib/process/index.js', diff --git a/config/webpack.prod.js b/config/webpack.prod.js index dbfd596c..ee023e9f 100644 --- a/config/webpack.prod.js +++ b/config/webpack.prod.js @@ -2,7 +2,6 @@ const config = require('./webpack.config'); config.mode = 'production'; config.entry = { 'formio.core.min.js': './lib/index.js', - 'formio.modules.min.js': './lib/modules/index.js', 'formio.min.js': './lib/sdk/index.js', 'formio.utils.min.js': './lib/utils/index.js', }; diff --git a/src/experimental/core.ts b/src/experimental/core.ts index 9141c199..fdb6c9e9 100644 --- a/src/experimental/core.ts +++ b/src/experimental/core.ts @@ -1,11 +1,10 @@ import 'core-js/features/object/from-entries'; import { Formio } from '../sdk'; -import { Evaluator, Utils } from '../utils'; +import { Evaluator, Utils, registerEvaluator } from '../utils'; import { Components, render } from './base'; import { Template } from './template'; import { merge } from 'lodash'; import components from './components'; -import modules from '../modules'; export default class FormioCore extends Formio { static Components = Components; @@ -60,7 +59,7 @@ export default class FormioCore extends Formio { if (!(Formio as any).Evaluator) { return; } - (Formio as any).Evaluator.registerEvaluator(plugin); + registerEvaluator(plugin); break; default: console.log('Unknown plugin option', key); @@ -95,4 +94,3 @@ export default class FormioCore extends Formio { } FormioCore.use(components); -FormioCore.use(modules); diff --git a/src/index.ts b/src/index.ts index 145533cd..95b113ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -export * from './modules'; export * from './utils'; export * from './process/validation'; export * from './process/validation/rules'; diff --git a/src/modules/index.ts b/src/modules/index.ts deleted file mode 100644 index 8de16338..00000000 --- a/src/modules/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { JSONLogicModule } from './jsonlogic'; -export default [JSONLogicModule]; diff --git a/src/modules/jsonlogic/index.ts b/src/modules/jsonlogic/index.ts deleted file mode 100644 index ee04f711..00000000 --- a/src/modules/jsonlogic/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { normalizeContext } from 'utils/formUtil'; -import { jsonLogic } from './jsonLogic'; -import { Evaluator, EvaluatorOptions, EvaluatorContext } from 'utils/Evaluator'; -export class JSONLogicEvaluator extends Evaluator { - public static evaluate( - func: any, - args: any = {}, - ret: any = '', - interpolate: boolean = false, - context: any = {}, - options: EvaluatorOptions = {}, - ) { - let returnVal = null; - if (typeof func === 'object') { - try { - returnVal = jsonLogic.apply(func, args); - } catch (err) { - returnVal = null; - console.warn(`An error occured within JSON Logic`, err); - } - } else { - returnVal = Evaluator.evaluate(func, args, ret, interpolate, context, options); - } - return returnVal; - } -} - -export type EvaluatorFn = (context: EvaluatorContext) => any; - -export function evaluate( - context: EvaluatorContext, - evaluation: string, - ret: string = 'result', - evalContextFn?: EvaluatorFn, - options: EvaluatorOptions = {}, -) { - const { evalContext, instance } = context; - const evalContextValue = evalContext - ? evalContext(normalizeContext(context)) - : normalizeContext(context); - if (evalContextFn) { - evalContextFn(evalContextValue); - } - if (instance && (instance as any).evaluate) { - return (instance as any).evaluate(evaluation, evalContextValue, ret, false, options); - } - return (JSONLogicEvaluator as any).evaluate( - evaluation, - evalContextValue, - ret, - false, - context, - options, - ); -} - -export function interpolate( - context: EvaluatorContext, - evaluation: string, - evalContextFn?: EvaluatorFn, -): string { - const { evalContext, instance } = context; - const evalContextValue = evalContext - ? evalContext(normalizeContext(context)) - : normalizeContext(context); - if (evalContextFn) { - evalContextFn(evalContextValue); - } - if (instance && (instance as any).evaluate) { - return (instance as any).interpolate(evaluation, evalContextValue, { - noeval: true, - }); - } - return (JSONLogicEvaluator as any).interpolate(evaluation, evalContextValue, { - noeval: true, - }); -} - -export * from './jsonLogic'; -export const JSONLogicModule = { - evaluator: JSONLogicEvaluator, -}; diff --git a/src/process/calculation/index.ts b/src/process/calculation/index.ts index 6d41166c..558700f1 100644 --- a/src/process/calculation/index.ts +++ b/src/process/calculation/index.ts @@ -1,4 +1,3 @@ -import { JSONLogicEvaluator } from 'modules/jsonlogic'; import { ProcessorFn, ProcessorFnSync, @@ -8,7 +7,7 @@ import { FetchScope, } from 'types'; import { set } from 'lodash'; -import { normalizeContext } from 'utils/formUtil'; +import { evaluate } from 'utils'; export const shouldCalculate = (context: CalculationContext): boolean => { const { component, config } = context; @@ -21,20 +20,25 @@ export const shouldCalculate = (context: CalculationContext): boolean => { export const calculateProcessSync: ProcessorFnSync = ( context: CalculationContext, ) => { - const { component, data, evalContext, scope, path, value } = context; - if (!shouldCalculate(context)) { + const { component, data, scope, path, value } = context; + if (!shouldCalculate(context) || !component.calculateValue) { return; } const calculationContext = (scope as FetchScope).fetched ? { ...context, data: { ...data, ...(scope as FetchScope).fetched } } : context; - const evalContextValue = evalContext - ? evalContext(normalizeContext(calculationContext)) - : normalizeContext(calculationContext); - evalContextValue.value = value || null; + if (!scope.calculated) scope.calculated = []; - const newValue = JSONLogicEvaluator.evaluate(component.calculateValue, evalContextValue, 'value'); + const newValue = evaluate( + component.calculateValue, + calculationContext, + 'value', + false, + (context) => { + context.value = value || null; + }, + ); // Only set a new value if it is not "null" which would be the case if no calculation occurred. if (newValue !== null) { diff --git a/src/process/clearHidden/__tests__/clearHidden.test.ts b/src/process/clearHidden/__tests__/clearHidden.test.ts index 1209f76b..ca1ecae3 100644 --- a/src/process/clearHidden/__tests__/clearHidden.test.ts +++ b/src/process/clearHidden/__tests__/clearHidden.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; -import { clearHiddenProcess } from '../index'; +import { clearHiddenProcessSync } from '../index'; describe('clearHidden', function () { it('Shoud not clear conditionally hidden component data when clearOnHide is false', function () { @@ -28,7 +28,7 @@ describe('clearHidden', function () { }, path: 'foo', }; - clearHiddenProcess(context); + clearHiddenProcessSync(context); expect(context.data).to.deep.equal({ foo: 'bar' }); }); @@ -57,7 +57,7 @@ describe('clearHidden', function () { }, path: 'foo', }; - clearHiddenProcess(context); + clearHiddenProcessSync(context); expect(context.data).to.deep.equal({}); }); @@ -81,7 +81,7 @@ describe('clearHidden', function () { }, path: 'foo', }; - clearHiddenProcess(context); + clearHiddenProcessSync(context); expect(context.data).to.deep.equal({ foo: 'bar' }); }); }); diff --git a/src/process/clearHidden/index.ts b/src/process/clearHidden/index.ts index 91e7f8e3..1d025301 100644 --- a/src/process/clearHidden/index.ts +++ b/src/process/clearHidden/index.ts @@ -5,6 +5,7 @@ import { ProcessorInfo, ProcessorFnSync, ConditionsScope, + ProcessorFn, } from 'types'; type ClearHiddenScope = ProcessorScope & { @@ -16,7 +17,7 @@ type ClearHiddenScope = ProcessorScope & { /** * This processor function checks components for the `hidden` property and unsets corresponding data */ -export const clearHiddenProcess: ProcessorFnSync = (context) => { +export const clearHiddenProcessSync: ProcessorFnSync = (context) => { const { component, data, value, scope, path } = context; // No need to unset the value if it's undefined @@ -45,8 +46,13 @@ export const clearHiddenProcess: ProcessorFnSync = (context) = } }; +export const clearHiddenProcess: ProcessorFn = async (context) => { + return clearHiddenProcessSync(context); +}; + export const clearHiddenProcessInfo: ProcessorInfo, void> = { name: 'clearHidden', shouldProcess: () => true, - processSync: clearHiddenProcess, + process: clearHiddenProcess, + processSync: clearHiddenProcessSync, }; diff --git a/src/process/defaultValue/index.ts b/src/process/defaultValue/index.ts index 37d88ed8..fcd2ed09 100644 --- a/src/process/defaultValue/index.ts +++ b/src/process/defaultValue/index.ts @@ -1,4 +1,3 @@ -import { JSONLogicEvaluator } from 'modules/jsonlogic'; import { ProcessorFn, ProcessorFnSync, @@ -7,7 +6,8 @@ import { DefaultValueContext, } from 'types'; import { set, has } from 'lodash'; -import { getComponentKey, normalizeContext } from 'utils/formUtil'; +import { evaluate } from 'utils'; +import { getComponentKey } from 'utils/formUtil'; export const hasCustomDefaultValue = (context: DefaultValueContext): boolean => { const { component } = context; @@ -38,7 +38,7 @@ export const customDefaultValueProcess: ProcessorFn = async ( export const customDefaultValueProcessSync: ProcessorFnSync = ( context: DefaultValueContext, ) => { - const { component, row, data, scope, evalContext, path } = context; + const { component, row, data, scope, path } = context; if (!hasCustomDefaultValue(context)) { return; } @@ -48,14 +48,12 @@ export const customDefaultValueProcessSync: ProcessorFnSync = ( } let defaultValue = null; if (component.customDefaultValue) { - const evalContextValue = evalContext - ? evalContext(normalizeContext(context)) - : normalizeContext(context); - evalContextValue.value = null; - defaultValue = JSONLogicEvaluator.evaluate( + defaultValue = evaluate( component.customDefaultValue, - evalContextValue, + context, 'value', + false, + (context) => (context.value = null), ); if (component.multiple && !Array.isArray(defaultValue)) { defaultValue = defaultValue ? [defaultValue] : []; diff --git a/src/process/fetch/index.ts b/src/process/fetch/index.ts index ea6c2d03..16d3e90a 100644 --- a/src/process/fetch/index.ts +++ b/src/process/fetch/index.ts @@ -8,8 +8,8 @@ import { FilterContext, } from 'types'; import { get, set } from 'lodash'; -import { Evaluator } from 'utils'; -import { getComponentKey, normalizeContext } from 'utils/formUtil'; +import { evaluate, interpolate } from 'utils'; +import { getComponentKey } from 'utils/formUtil'; export const shouldFetch = (context: FetchContext): boolean => { const { component, config } = context; @@ -23,7 +23,7 @@ export const shouldFetch = (context: FetchContext): boolean => { }; export const fetchProcess: ProcessorFn = async (context: FetchContext) => { - const { component, row, evalContext, path, scope, config } = context; + const { component, row, path, scope, config } = context; let _fetch: FetchFn | null = null; try { _fetch = context.fetch ? context.fetch : fetch; @@ -38,10 +38,7 @@ export const fetchProcess: ProcessorFn = async (context: FetchContex return; } if (!scope.fetched) scope.fetched = {}; - const evalContextValue = evalContext - ? evalContext(normalizeContext(context)) - : normalizeContext(context); - const url = Evaluator.interpolateString(get(component, 'fetch.url', ''), evalContextValue); + const url = interpolate(get(component, 'fetch.url', ''), context); if (!url) { return; } @@ -66,7 +63,7 @@ export const fetchProcess: ProcessorFn = async (context: FetchContex request.headers['Accept'] = '*/*'; request.headers['user-agent'] = 'Form.io DataSource Component'; get(component, 'fetch.headers', []).map((header: any) => { - header.value = Evaluator.interpolateString(header.value, evalContextValue); + header.value = interpolate(header.value, context); if (header.value && header.key) { request.headers[header.key] = header.value; } @@ -79,7 +76,7 @@ export const fetchProcess: ProcessorFn = async (context: FetchContex const body = get(component, 'fetch.specifyBody', ''); if (request.method === 'POST') { - request.body = JSON.stringify(Evaluator.evaluate(body, evalContextValue, 'body')); + request.body = JSON.stringify(evaluate(body, context, 'body')); } try { @@ -93,10 +90,10 @@ export const fetchProcess: ProcessorFn = async (context: FetchContex row, key, mapFunction - ? Evaluator.evaluate( + ? evaluate( mapFunction, { - ...evalContextValue, + ...context, ...{ responseData: result }, }, 'value', diff --git a/src/process/validation/rules/validateAvailableItems.ts b/src/process/validation/rules/validateAvailableItems.ts index 55f9d5fd..f7a6e74a 100644 --- a/src/process/validation/rules/validateAvailableItems.ts +++ b/src/process/validation/rules/validateAvailableItems.ts @@ -1,6 +1,6 @@ import { isEmpty, isUndefined, difference } from 'lodash'; import { FieldError, ProcessorError } from 'error'; -import { Evaluator } from 'utils'; +import { evaluate } from 'utils'; import { RadioComponent, SelectComponent, @@ -102,7 +102,7 @@ async function getAvailableSelectValues(component: SelectComponent, context: Val } } case 'custom': { - const customItems = Evaluator.evaluate( + const customItems = evaluate( component.data.custom, { values: [], @@ -178,7 +178,7 @@ function getAvailableSelectValuesSync(component: SelectComponent, context: Valid } } case 'custom': { - const customItems = Evaluator.evaluate( + const customItems = evaluate( component.data.custom, { values: [], diff --git a/src/process/validation/rules/validateCustom.ts b/src/process/validation/rules/validateCustom.ts index 824231ae..eacc6d81 100644 --- a/src/process/validation/rules/validateCustom.ts +++ b/src/process/validation/rules/validateCustom.ts @@ -1,7 +1,6 @@ import { RuleFn, RuleFnSync, ProcessorInfo, ValidationContext } from 'types'; import { FieldError, ProcessorError } from 'error'; -import { Evaluator } from 'utils'; -import { normalizeContext } from 'utils/formUtil'; +import { evaluate } from 'utils'; export const validateCustom: RuleFn = async (context: ValidationContext) => { return validateCustomSync(context); @@ -17,30 +16,26 @@ export const shouldValidate = (context: ValidationContext) => { }; export const validateCustomSync: RuleFnSync = (context: ValidationContext) => { - const { component, data, row, value, index, instance, evalContext } = context; + const { component, index, instance, value, data, row, submission } = context; const customValidation = component.validate?.custom; try { - if (!shouldValidate(context)) { + if (!shouldValidate(context) || !customValidation) { return null; } - const ctx = instance?.evalContext - ? instance.evalContext() - : evalContext - ? evalContext(normalizeContext(context)) - : normalizeContext(context); - const evalContextValue = { - ...ctx, - component, - data, - row, - rowIndex: typeof index === 'number' ? index : ctx.rowIndex, - instance, - valid: true, - input: value, - }; - - const isValid = Evaluator.evaluate(customValidation, evalContextValue, 'valid', true, {}, {}); + const validationContext: any = instance?.evalContext ? instance.evalContext() : context; + + // We have to augment some of the evalContext values here if the evalContext comes from the instance + const isValid = evaluate(customValidation, validationContext, 'valid', true, (context) => { + context.component = component; + context.data = data; + context.row = row; + context.rowIndex = typeof index === 'number' ? index : validationContext.rowIndex; + context.instance = instance; + context.valid = true; + context.input = value; + context.submission = submission; + }); if (isValid === null || isValid === true) { return null; diff --git a/src/process/validation/rules/validateJson.ts b/src/process/validation/rules/validateJson.ts index d713556d..7aa9541f 100644 --- a/src/process/validation/rules/validateJson.ts +++ b/src/process/validation/rules/validateJson.ts @@ -1,9 +1,8 @@ -import { JSONLogicEvaluator } from 'modules/jsonlogic'; +import { evaluate } from 'utils'; import { FieldError } from 'error'; import { RuleFn, RuleFnSync, ValidationContext } from 'types'; import { ProcessorInfo } from 'types/process/ProcessorInfo'; import { isObject } from 'lodash'; -import { normalizeContext } from 'utils/formUtil'; export const shouldValidate = (context: ValidationContext) => { const { component } = context; @@ -18,24 +17,15 @@ export const validateJson: RuleFn = async (context: ValidationContext) => { }; export const validateJsonSync: RuleFnSync = (context: ValidationContext) => { - const { component, value, evalContext } = context; + const { component, value } = context; if (!shouldValidate(context)) { return null; } const func = component?.validate?.json; - const evalContextValue = evalContext - ? evalContext(normalizeContext(context)) - : normalizeContext(context); - evalContextValue.value = value || null; - const valid: true | string = JSONLogicEvaluator.evaluate( - func, - { - ...evalContextValue, - input: value, - }, - 'valid', - ); + const valid: true | string = evaluate(func, context, 'valid', false, (context) => { + context.value = value || null; + }); if (valid === null) { return null; } diff --git a/src/types/PassedComponentInstance.ts b/src/types/PassedComponentInstance.ts index fc40c331..4c089dea 100644 --- a/src/types/PassedComponentInstance.ts +++ b/src/types/PassedComponentInstance.ts @@ -1,6 +1,7 @@ import { Component } from './Component'; import { DataObject } from './DataObject'; import { Form } from './Form'; +import { Evaluator } from 'utils/Evaluator'; export type PassedComponentInstance = { evalContext: () => { @@ -16,8 +17,8 @@ export type PassedComponentInstance = { form: Form; options: Record; }; - evaluate: (expression: string, additionalContext?: Record) => any; - interpolate: (text: string, additionalContext?: Record) => string; + evaluate?: typeof Evaluator.evaluate; + interpolate?: typeof Evaluator.interpolate; shouldSkipValidation: (data?: DataObject, row?: DataObject) => boolean; loadedOptions?: Array<{ invalid: boolean; value: any; label: string }>; }; diff --git a/src/types/process/ProcessConfig.ts b/src/types/process/ProcessConfig.ts index 0d8d5155..99b62078 100644 --- a/src/types/process/ProcessConfig.ts +++ b/src/types/process/ProcessConfig.ts @@ -1,7 +1,8 @@ -import { Evaluator, Database } from 'utils'; +import { Database } from 'utils'; +import { Evaluator } from 'utils'; export type ProcessConfig = { database?: Database; - evaluator?: Evaluator; + evaluator?: typeof Evaluator; token?: string; }; diff --git a/src/utils/Evaluator.ts b/src/utils/Evaluator.ts index 815259b7..48117735 100644 --- a/src/utils/Evaluator.ts +++ b/src/utils/Evaluator.ts @@ -1,35 +1,26 @@ import { noop, trim, keys, get, set, isObject, values } from 'lodash'; +import { jsonLogic } from './jsonlogic'; -export interface EvaluatorOptions { +export type EvaluatorOptions = { noeval?: boolean; data?: any; -} - -export type EvaluatorContext = { - evalContext?: (context: any) => any; - instance?: any; - [key: string]: any; + formModule?: string; }; -// BaseEvaluator is for extending. -export class BaseEvaluator { - static templateSettings = { +export class DefaultEvaluator { + noeval: boolean; + templateSettings = { interpolate: /{{([\s\S]+?)}}/g, evaluate: /\{%([\s\S]+?)%\}/g, escape: /\{\{\{([\s\S]+?)\}\}\}/g, }; - private static _noeval = false; - public static get noeval(): boolean { - return BaseEvaluator._noeval; + constructor(options: EvaluatorOptions = {}) { + this.noeval = !!options.noeval; } - public static set noeval(value: boolean) { - BaseEvaluator._noeval = value; - } - - public static evaluator(func: any, ...params: any) { - if (Evaluator.noeval) { + evaluator(func: any, ...params: any) { + if (this.noeval) { console.warn('No evaluations allowed for this renderer.'); return noop; } @@ -42,7 +33,7 @@ export class BaseEvaluator { return new Function(...params, func); } - public static interpolateString(rawTemplate: string, data: any, options: EvaluatorOptions = {}) { + interpolateString(rawTemplate: string, data: any, options: EvaluatorOptions = {}) { if (!rawTemplate) { return ''; } @@ -51,7 +42,7 @@ export class BaseEvaluator { } return rawTemplate.replace(/({{\s*(.*?)\s*}})/g, (match, $1, $2) => { // If this is a function call and we allow evals. - if ($2.indexOf('(') !== -1 && !(Evaluator.noeval || options.noeval)) { + if ($2.indexOf('(') !== -1 && !(this.noeval || options.noeval)) { return $2.replace( /([^(]+)\(([^)]+)\s*\);?/, (evalMatch: any, funcName: string, args: any) => { @@ -67,7 +58,7 @@ export class BaseEvaluator { return get(data, arg); }); } - return Evaluator.evaluate(func, args, '', false, data, options); + return this.evaluate(func, args, '', false, data, options); } return ''; }, @@ -96,8 +87,8 @@ export class BaseEvaluator { }); } - public static interpolate(rawTemplate: any, data: any, options: EvaluatorOptions = {}) { - if (typeof rawTemplate === 'function' && !(Evaluator.noeval || options.noeval)) { + interpolate(rawTemplate: any, data: any, options: EvaluatorOptions = {}) { + if (typeof rawTemplate === 'function' && !(this.noeval || options.noeval)) { try { return rawTemplate(data); } catch (err: any) { @@ -106,7 +97,7 @@ export class BaseEvaluator { } } - return Evaluator.interpolateString(String(rawTemplate), data, options); + return this.interpolateString(String(rawTemplate), data, options); } /** @@ -116,7 +107,7 @@ export class BaseEvaluator { * @param args * @return {*} */ - public static evaluate( + evaluate( func: any, args: any = {}, ret: any = '', @@ -130,22 +121,37 @@ export class BaseEvaluator { if (!args.form && args.instance) { args.form = get(args.instance, 'root._form', {}); } - const componentKey = component.key; - if (typeof func === 'string') { + if (typeof func === 'object') { + try { + returnVal = jsonLogic.apply(func, args); + } catch (err) { + returnVal = null; + console.warn(`An error occured within JSON Logic`, err); + } + return returnVal; + } else if (typeof func === 'string') { if (ret) { func = `var ${ret};${func};return ${ret}`; } + if (options.formModule) { + func = `const module = ${options.formModule}; + if (module.options?.form?.evalContext) { + Object.keys(module.options.form.evalContext).forEach((key) => globalThis[key] = module[key]); + } + ${func}; + `; + } if (interpolate) { - func = BaseEvaluator.interpolate(func, args, options); + func = this.interpolate(func, args, options); } try { - if (Evaluator.noeval || options.noeval) { + if (this.noeval || options.noeval) { func = noop; } else { - func = Evaluator.evaluator(func, args, context); + func = this.evaluator(func, args, context); } args = values(args); } catch (err) { @@ -157,7 +163,7 @@ export class BaseEvaluator { if (typeof func === 'function') { try { - returnVal = Evaluator.execute(func, args, context, options); + returnVal = this.execute(func, args, context, options); } catch (err) { returnVal = null; console.warn(`An error occured within custom function for ${componentKey}`, err); @@ -175,14 +181,9 @@ export class BaseEvaluator { * @param args * @returns */ - public static execute( - func: string | any, - args: any, - context: any = {}, - options: EvaluatorOptions = {}, - ) { + execute(func: string | any, args: any, context: any = {}, options: EvaluatorOptions = {}) { options = isObject(options) ? options : { noeval: options }; - if (Evaluator.noeval || options.noeval) { + if (this.noeval || options.noeval) { console.warn('No evaluations allowed for this renderer.'); return; } @@ -190,15 +191,9 @@ export class BaseEvaluator { } } -// The extendable evaluator -export class Evaluator extends BaseEvaluator { - /** - * Allow external modules the ability to extend the Evaluator. - * @param evaluator - */ - public static registerEvaluator(evaluator: any) { - Object.keys(evaluator).forEach((key) => { - (Evaluator as any)[key] = evaluator[key]; - }); - } +// The mutable singleton instance +export let Evaluator: DefaultEvaluator = new DefaultEvaluator(); + +export function registerEvaluator(override: DefaultEvaluator) { + Evaluator = override; } diff --git a/src/utils/conditions.ts b/src/utils/conditions.ts index cf568649..9b45d787 100644 --- a/src/utils/conditions.ts +++ b/src/utils/conditions.ts @@ -1,8 +1,8 @@ -import { ConditionsContext, JSONConditional, LegacyConditional, SimpleConditional } from 'types'; -import { EvaluatorFn, evaluate, JSONLogicEvaluator } from 'modules/jsonlogic'; -import { getComponent, getComponentValue, normalizeContext } from './formUtil'; import { has, isObject, map, every, some, find, filter, isString } from 'lodash'; +import { getComponent, getComponentValue } from './formUtil'; import ConditionOperators from './operators'; +import { evaluate } from './utils'; +import { ConditionsContext, JSONConditional, LegacyConditional, SimpleConditional } from 'types'; export const isJSONConditional = (conditional: any): conditional is JSONConditional => { return conditional && conditional.json && isObject(conditional.json); @@ -41,11 +41,10 @@ export function checkCustomConditional( context: ConditionsContext, variable: string = 'show', ): boolean | null { - const { evalContext } = context; if (!condition) { return null; } - const value = evaluate(context, condition, variable, evalContext as EvaluatorFn); + const value = evaluate(condition, context, variable); if (value === null) { return null; } @@ -90,14 +89,10 @@ export function checkJsonConditional( conditional: JSONConditional, context: ConditionsContext, ): boolean | null { - const { evalContext } = context; if (!conditional || !isJSONConditional(conditional)) { return null; } - const evalContextValue = evalContext - ? evalContext(normalizeContext(context)) - : normalizeContext(context); - return JSONLogicEvaluator.evaluate(conditional.json, evalContextValue); + return evaluate(conditional.json as string, context); } /** diff --git a/src/utils/formUtil/index.ts b/src/utils/formUtil/index.ts index 93491a27..c010934a 100644 --- a/src/utils/formUtil/index.ts +++ b/src/utils/formUtil/index.ts @@ -1347,17 +1347,24 @@ export function getComponentErrorField(component: Component, context: Validation return Evaluator.interpolate(toInterpolate, context); } +/** + * Normalize a context object so that it contains the correct paths and data, and so it can pass into and out of a sandbox for evaluation + * @param context + * @returns + */ export function normalizeContext(context: any): any { - const { data, paths, local } = context; - return paths - ? { - ...context, - ...{ - path: paths.localDataPath, - data: getComponentLocalData(paths, data, local), - }, - } - : context; + const { data, paths, local, path, form, submission, row, component, instance, value } = context; + return { + path: paths ? paths.localDataPath : path, + data: paths ? getComponentLocalData(paths, data, local) : data, + form, + submission, + row, + component, + instance, + value, + input: value, + }; } export { eachComponent, eachComponentData, eachComponentAsync, eachComponentDataAsync }; diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts new file mode 100644 index 00000000..e85cb0d8 --- /dev/null +++ b/src/utils/i18n.ts @@ -0,0 +1,167 @@ +import enTranslation from './translations/en'; +import { fastCloneDeep } from '../utils/fastCloneDeep'; +import { isEmpty } from 'lodash'; +import { Evaluator } from './Evaluator'; + +export const coreEnTranslation = enTranslation; + +type TranslationDictionary = { + [key: string]: string; +}; + +export type LanguageResources = { + [language: string]: { + translation: TranslationDictionary; + }; +}; + +export type I18nConfig = { + lng: string; + nsSeparator: string; + keySeparator: string; + pluralSeparator: string; + contextSeparator: string; + resources: LanguageResources; +}; + +export type LanguagesMap = { + [language: string]: TranslationDictionary; +}; + +export type I18nOptions = Partial & { + language?: string; +} & Omit<{ [language: string]: TranslationDictionary }, 'language'>; + +export const i18nConfig: I18nConfig = { + lng: 'en', + nsSeparator: '::', + keySeparator: '.|.', + pluralSeparator: '._.', + contextSeparator: '._.', + resources: { + en: { + translation: fastCloneDeep(enTranslation), + }, + }, +}; + +const i18Defaults: LanguagesMap = {}; +for (const lang in i18nConfig.resources) { + if (i18nConfig.resources.hasOwnProperty(lang)) { + i18Defaults[lang] = i18nConfig.resources[lang].translation; + } +} + +/** + * This file is used to mimic the i18n library interface. + */ +export class I18n { + static languages = i18Defaults; + languages = fastCloneDeep(I18n.languages || {}); + defaultKeys = I18n.languages?.en || {}; + language = 'en'; + currentLanguage = i18Defaults.en; + + constructor(languages = {}) { + this.setLanguages(languages, false); + this.changeLanguage(this.language); + } + + static setDefaultTranslations(languages: LanguagesMap) { + if (isEmpty(languages)) { + return; + } + for (const lang in languages) { + if (lang !== 'language' && languages.hasOwnProperty(lang)) { + if (!this.languages[lang]) { + this.languages[lang] = {}; + } + this.languages[lang] = { ...languages[lang], ...this.languages[lang] }; + } + } + } + + setLanguages(languages: I18nOptions, noDefaultOverride: boolean) { + if (languages.resources) { + for (const lang in languages.resources) { + if (languages.resources.hasOwnProperty(lang)) { + languages[lang] = languages.resources[lang].translation; + } + } + delete languages.resources; + } + if (languages.lng) { + languages.language = languages.lng; + delete languages.lng; + } + // Do not use these configurations. + delete languages.nsSeparator; + delete languages.keySeparator; + delete languages.pluralSeparator; + delete languages.contextSeparator; + + // Now establish the languages default. + if (languages.language) { + this.language = languages.language; + } + for (const lang in languages) { + if (lang !== 'language' && languages.hasOwnProperty(lang)) { + if (!this.languages[lang]) { + this.languages[lang] = {}; + } + this.languages[lang] = noDefaultOverride + ? { ...languages[lang], ...this.languages[lang] } + : { ...this.languages[lang], ...languages[lang] }; + } + } + } + + static init(languages = {}) { + return new I18n(languages); + } + + dir(lang = '') { + lang = lang || this.language; + const rtls = ['ar', 'he', 'fa', 'ps', 'ur']; + return rtls.includes(lang) ? 'rtl' : 'ltr'; + } + + static createInstance() { + return new I18n(); + } + + changeLanguage(language: string, ready?: () => any) { + if (!this.languages[language]) { + language = 'en'; + } + this.language = language; + this.currentLanguage = this.languages[language] ? this.languages[language] : {}; + if (ready) { + ready(); + } + } + + addResourceBundle(language: string, type: string, strings: TranslationDictionary) { + this.languages[language] = strings; + } + + t(text: string, data: any, ...args: any[]) { + let currentTranslation = this.currentLanguage[text]; + // provide compatibility with cases where the entire phrase is used as a key + // get the phrase that is possibly being used as a key + const defaultKey = this.defaultKeys[text]; + if (defaultKey && this.currentLanguage[defaultKey]) { + // get translation using the phrase as a key + currentTranslation = this.currentLanguage[defaultKey]; + } + + if (currentTranslation) { + const customTranslationFieldName = data?.field; + if (customTranslationFieldName && this.currentLanguage[customTranslationFieldName]) { + data.field = this.currentLanguage[customTranslationFieldName]; + } + return Evaluator.interpolateString(currentTranslation, data, ...args); + } + return Evaluator.interpolateString(text, data, ...args); + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 190fec6a..2e6a6a15 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,11 +1,12 @@ -export { Evaluator, EvaluatorOptions, BaseEvaluator } from './Evaluator'; -export { JSONLogicEvaluator } from '../modules/jsonlogic'; export { sanitize } from './sanitize'; export { override } from './override'; export { unwind } from './unwind'; +export { Evaluator, registerEvaluator, EvaluatorOptions, DefaultEvaluator } from './Evaluator'; +export { jsonLogic } from './jsonlogic'; export * as Utils from './formUtil'; export * as dom from './dom'; export * from './utils'; +export * from './i18n'; export * from './date'; export * from './mask'; export * from './fastCloneDeep'; diff --git a/src/modules/jsonlogic/__tests__/operators.test.ts b/src/utils/jsonlogic/__tests__/operators.test.ts similarity index 98% rename from src/modules/jsonlogic/__tests__/operators.test.ts rename to src/utils/jsonlogic/__tests__/operators.test.ts index b454f21e..edaf3548 100644 --- a/src/modules/jsonlogic/__tests__/operators.test.ts +++ b/src/utils/jsonlogic/__tests__/operators.test.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { jsonLogic } from '../jsonLogic'; +import { jsonLogic } from '..'; describe('Lodash operators', function () { describe('Arrays', function () {}); diff --git a/src/modules/jsonlogic/jsonLogic.ts b/src/utils/jsonlogic/index.ts similarity index 80% rename from src/modules/jsonlogic/jsonLogic.ts rename to src/utils/jsonlogic/index.ts index 6f67b7b8..cd04e093 100644 --- a/src/modules/jsonlogic/jsonLogic.ts +++ b/src/utils/jsonlogic/index.ts @@ -1,10 +1,10 @@ import jsonLogic from 'json-logic-js'; import { dayjs } from 'utils/date'; -import { _ } from './operators'; +import { operators } from './operators'; // Configure JsonLogic -for (const operator in _) { - jsonLogic.add_operation(`_${operator}`, _[operator]); +for (const operator in operators) { + jsonLogic.add_operation(`_${operator}`, operators[operator]); } // Retrieve Any Date diff --git a/src/modules/jsonlogic/operators.ts b/src/utils/jsonlogic/operators.ts similarity index 99% rename from src/modules/jsonlogic/operators.ts rename to src/utils/jsonlogic/operators.ts index afd83955..7ed17fd1 100644 --- a/src/modules/jsonlogic/operators.ts +++ b/src/utils/jsonlogic/operators.ts @@ -246,7 +246,7 @@ import { toPath, uniqueId, } from 'lodash'; -export const _: any = { +export const operators: Record = { chunk, compact, concat, diff --git a/src/utils/logic.ts b/src/utils/logic.ts index 182eb4ca..5c5ba905 100644 --- a/src/utils/logic.ts +++ b/src/utils/logic.ts @@ -16,7 +16,7 @@ import { LogicActionValue, } from 'types/AdvancedLogic'; import { get, set, clone, isEqual, assign } from 'lodash'; -import { evaluate, interpolate } from 'modules/jsonlogic'; +import { evaluate, interpolate } from 'utils/utils'; import { setComponentScope } from 'utils/formUtil'; export const hasLogic = (context: LogicContext): boolean => { @@ -114,7 +114,7 @@ export function setActionStringProperty( ? (action as any)[action.property.component] : action.text; const currentValue = get(component, property, ''); - const newValue = interpolate({ ...context, value: '' }, textValue, (evalContext: any) => { + const newValue = interpolate(textValue, { ...context, value: '' }, (evalContext: any) => { evalContext.value = currentValue; }); if (newValue !== currentValue) { @@ -137,7 +137,7 @@ export function setActionProperty(context: LogicContext, action: LogicActionProp export function setValueProperty(context: LogicContext, action: LogicActionValue) { const { component, data, path } = context; const oldValue = get(data, path); - const newValue = evaluate(context, action.value, 'value', (evalContext: any) => { + const newValue = evaluate(action.value, context, 'value', false, (evalContext: any) => { evalContext.value = clone(oldValue); }); if ( @@ -157,9 +157,10 @@ export function setMergeComponentSchema( const { component, data, path } = context; const oldValue = get(data, path); const schema = evaluate( - { ...context, value: {} }, action.schemaDefinition, + { ...context, value: {} }, 'schema', + false, (evalContext: any) => { evalContext.value = clone(oldValue); }, diff --git a/src/utils/translations/en.ts b/src/utils/translations/en.ts new file mode 100644 index 00000000..8698d9ac --- /dev/null +++ b/src/utils/translations/en.ts @@ -0,0 +1,26 @@ +import { EN_ERRORS } from 'processes/validation/i18n/en'; + +export default { + ...EN_ERRORS, + month: 'Month', + day: 'Day', + year: 'Year', + january: 'January', + february: 'February', + march: 'March', + april: 'April', + may: 'May', + june: 'June', + july: 'July', + august: 'August', + september: 'September', + october: 'October', + november: 'November', + december: 'December', + yes: 'Yes', + no: 'No', + surveyQuestion: 'Question', + surveyQuestionValue: 'Value', + complexData: '[Complex Data]', // also in premium en.ts + dots: 'Dots', // also in premium en.ts +}; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 9f705c28..7291dc29 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,5 +1,7 @@ import { isBoolean, isString } from 'lodash'; -import { ResourceToDomOptions } from 'types'; +import { EvaluatorOptions, Evaluator } from './Evaluator'; +import { normalizeContext } from './formUtil'; +import { PassedComponentInstance, ResourceToDomOptions } from 'types'; /** * Escapes RegEx characters in provided String value. @@ -99,3 +101,62 @@ export function attachResourceToDom(options: ResourceToDomOptions) { } }); } + +type EvaluatorContext = { + evalContext?: (context: any) => any; + instance?: PassedComponentInstance; + [key: string]: any; +}; +export type EvaluatorFn = (context: EvaluatorContext) => any; + +/** + * A convenience function that wraps Evaluator.evaluate and normalizes context values + * @param evaluation - The code string to evaluate + * @param context - The processor context + * @param ret - The return value + * @param interpolate - Whether or not to interpolate the code string before evaluating + * @param evalContextFn - A callback to mutate the context value after it has been normalized + * @param options - Options to pass to the Evaluator + * @returns {*} - Returns the result of the evaluation + */ +export function evaluate( + evaluation: string, + context: EvaluatorContext, + ret: string = 'result', + interpolate: boolean = false, + evalContextFn?: EvaluatorFn, + options: EvaluatorOptions = {}, +) { + const { instance, form } = context; + const normalizedContext = normalizeContext(context); + if (evalContextFn) { + evalContextFn(normalizedContext); + } + if (form?.module) { + options = { ...options, formModule: form.module }; + } + if (instance && instance.evaluate) { + return instance.evaluate(evaluation, normalizedContext, ret, interpolate, options); + } + return Evaluator.evaluate(evaluation, normalizedContext, ret, interpolate, context, options); +} + +export function interpolate( + evaluation: string, + context: EvaluatorContext, + evalContextFn?: EvaluatorFn, +): string { + const { instance } = context; + const normalizedContext = normalizeContext(context); + if (evalContextFn) { + evalContextFn(normalizedContext); + } + if (instance && instance.interpolate) { + return instance.interpolate(evaluation, normalizedContext, { + noeval: true, + }); + } + return Evaluator.interpolate(evaluation, normalizedContext, { + noeval: true, + }); +}