diff --git a/packages/core/ui/builder/binding-builder.ts b/packages/core/ui/builder/binding-builder.ts index 30c060358..6de8832e3 100644 --- a/packages/core/ui/builder/binding-builder.ts +++ b/packages/core/ui/builder/binding-builder.ts @@ -1,5 +1,5 @@ // regex that contains all symbols applicable for expression used to AI detect an expression. -const expressionSymbolsRegex = /[\+\-\*\/%\?:<>=!\|&\(\)^~]|\$\{.+\}/; +const expressionSymbolsRegex = /[\+\-\*\/%\?:<>=!\|&\(\)^~]|^`.*\$\{.+\}.*`$/; export namespace bindingConstants { export const sourceProperty = 'sourceProperty'; diff --git a/packages/core/ui/core/bindable/bindable-expressions.ts b/packages/core/ui/core/bindable/bindable-expressions.ts index dee9f0591..1861e079f 100644 --- a/packages/core/ui/core/bindable/bindable-expressions.ts +++ b/packages/core/ui/core/bindable/bindable-expressions.ts @@ -24,6 +24,7 @@ const binaryOperators = { '+': (l, r) => l + r, '-': (l, r) => l - r, '*': (l, r) => l * r, + '**': (l, r) => l ** r, '/': (l, r) => l / r, '%': (l, r) => l % r, '<': (l, r) => l < r, @@ -63,54 +64,53 @@ const expressionParsers = { const left = convertExpressionToValue(expression.left, model, isBackConvert, changedModel); - if (expression.operator == '|' && expression.right.type == 'CallExpression') { - expression.right.requiresConverter = true; + let converterExpression = expression.right; + if (expression.operator == '|') { + if (converterExpression.type == 'ChainExpression') { + converterExpression = converterExpression.expression; + } + if (converterExpression.type == 'CallExpression') { + !converterExpression.arguments.includes(expression.left) && converterExpression.arguments.unshift(expression.left); + expression.right.nsIsCallable = true; + converterExpression = converterExpression.callee; + } + + switch (converterExpression.type) { + case 'Identifier': + case 'MemberExpression': + case 'NewExpression': + converterExpression.nsRequiresConverter = true; + converterExpression.nsIsPendingCall = true; + break; + default: + throw new Error('Invalid converter syntax'); + } } + const right = convertExpressionToValue(expression.right, model, isBackConvert, changedModel); if (expression.operator == '|') { - if (expression.right.requiresConverter && right != null) { - right.args.unshift(left); - return right.callback.apply(right.context, right.args); + if (converterExpression.nsRequiresConverter) { + return expression.right.nsIsCallable ? right : right?.(left); } - throw new Error('Invalid converter after ' + expression.operator + ' operator'); + throw new Error('Invalid converter syntax'); } return binaryOperators[expression.operator](left, right); }, 'CallExpression': (expression: ASTExpression, model, isBackConvert: boolean, changedModel) => { - expression.callee.requiresObjectAndProperty = true; + expression.callee.nsIsPendingCall = true; + const callback = convertExpressionToValue(expression.callee, model, isBackConvert, changedModel); - const { object, property } = convertExpressionToValue(expression.callee, model, isBackConvert, changedModel); - - let callback; - if (object == FORCED_CHAIN_VALUE) { - callback = undefined; - } else { - callback = expression.callee.optional ? object?.[property] : object[property]; - } - - if ((!expression.optional || expression.requiresConverter) && isNullOrUndefined(callback)) { + if (!expression.optional && isNullOrUndefined(callback)) { throw new Error('Cannot perform a call using a null or undefined property'); } - if (expression.requiresConverter) { - if (isFunction(callback)) { - callback = { toView: callback }; - } else if (!isObject(callback) || !isFunction(callback.toModel) && !isFunction(callback.toView)) { - throw new Error('Invalid converter call'); - } - } - const parsedArgs = []; for (let argument of expression.arguments) { let value = convertExpressionToValue(argument, model, isBackConvert, changedModel); argument.type == 'SpreadElement' ? parsedArgs.push(...value) : parsedArgs.push(value); } - - if (expression.requiresConverter) { - return getConverter(callback, object, parsedArgs, isBackConvert); - } - return expression.optional ? callback?.apply(object, parsedArgs) : callback.apply(object, parsedArgs); + return expression.optional ? callback?.(...parsedArgs) : callback(...parsedArgs); }, 'ChainExpression': (expression: ASTExpression, model, isBackConvert: boolean, changedModel) => { return convertExpressionToValue(expression.expression, model, isBackConvert, changedModel); @@ -121,10 +121,11 @@ const expressionParsers = { }, 'Identifier': (expression: ASTExpression, model, isBackConvert: boolean, changedModel) => { const context = getContext(expression.name, model, changedModel); - if (expression.requiresObjectAndProperty) { - return { object: context, property: expression.name }; - } - return context[expression.name]; + let value = context[expression.name]; + if (expression.nsRequiresConverter) { + value = getConverterCallback(value, isBackConvert); + } + return expression.nsIsPendingCall && typeof value === 'function' ? value.bind(context) : value; }, 'Literal': (expression: ASTExpression, model, isBackConvert: boolean, changedModel) => { return expression.regex != null ? new RegExp(expression.regex.pattern, expression.regex.flags) : expression.value; @@ -138,34 +139,34 @@ const expressionParsers = { }, 'MemberExpression': (expression: ASTExpression, model, isBackConvert: boolean, changedModel) => { if (expression.object.type == 'MemberExpression') { - expression.object.isChained = true; + expression.object.nsIsChained = true; } const object = convertExpressionToValue(expression.object, model, isBackConvert, changedModel); const property = expression.computed ? convertExpressionToValue(expression.property, model, isBackConvert, changedModel) : expression.property?.name; - const propertyInfo = { object, property }; - - if (expression.requiresObjectAndProperty) { - return propertyInfo; - } /** - * If first member is undefined, make sure that no error is thrown later but return undefined instead. - * This behaviour is kept in order to cope with components whose binding context takes a bit long to load. - * Old parser would return undefined for an expression like 'property1.property2.property3' - * even if expression as a whole consisted of undefined properties. - * The new one will keep the same principle only if first member is undefined for safety reasons. + * If an expression parent property is null or undefined, apply null-safety. + * This behaviour also helps cope with components whose binding context takes a bit longer to load. + * Old parser would be null-safe for properties and sub-properties + * even if expression as a whole consisted of undefined ones. + * The new parser will keep the same principle only if parent property is null or undefined, resulting in better control over code and errors. * It meddles with members specifically, so that it will not affect expression result as a whole. * For example, an 'isLoading || isBusy' expression will be validated as 'undefined || undefined' * if context is not ready. */ - if (object === undefined && expression.object.type == 'Identifier') { - return expression.isChained ? FORCED_CHAIN_VALUE : object; + if (object == null && expression.object.type == 'Identifier') { + return expression.nsIsChained ? FORCED_CHAIN_VALUE : undefined; } if (object == FORCED_CHAIN_VALUE) { - return expression.isChained ? object : undefined; + return expression.nsIsChained ? object : undefined; } - return expression.optional ? object?.[property] : object[property]; + + let value = expression.optional ? object?.[property] : object[property]; + if (expression.nsRequiresConverter) { + value = getConverterCallback(value, isBackConvert); + } + return expression.nsIsPendingCall && typeof value === 'function' ? value.bind(object) : value; }, 'NewExpression': (expression: ASTExpression, model, isBackConvert: boolean, changedModel) => { const callback = convertExpressionToValue(expression.callee, model, isBackConvert, changedModel); @@ -174,7 +175,12 @@ const expressionParsers = { let value = convertExpressionToValue(argument, model, isBackConvert, changedModel); argument.type == 'SpreadElement' ? parsedArgs.push(...value) : parsedArgs.push(value); } - return new callback(...parsedArgs); + + let value = new callback(...parsedArgs); + if (expression.nsRequiresConverter) { + value = getConverterCallback(value, isBackConvert); + } + return value; }, 'ObjectExpression': (expression: ASTExpression, model, isBackConvert: boolean, changedModel) => { const parsedObject = {}; @@ -198,12 +204,14 @@ const expressionParsers = { }, 'TemplateLiteral': (expression: ASTExpression, model, isBackConvert: boolean, changedModel) => { let parsedText = ''; - for (let q of expression.quasis) { - parsedText += convertExpressionToValue(q, model, isBackConvert, changedModel); - } + const length = expression.quasis.length; - for (let ex of expression.expressions) { - parsedText += convertExpressionToValue(ex, model, isBackConvert, changedModel); + for (let i = 0; i < length; i++) { + let q = expression.quasis[i]; + parsedText += convertExpressionToValue(q, model, isBackConvert, changedModel); + if (!q.tail) { + parsedText += convertExpressionToValue(expression.expressions[i], model, isBackConvert, changedModel); + } } return parsedText; }, @@ -225,14 +233,18 @@ function getContext(key, model, changedModel) { return context; } -function getConverter(converterSchema, context, args, isBackConvert: boolean) { - const converter = { callback: null, context, args }; - let callback = isBackConvert ? converterSchema.toModel : converterSchema.toView; - if (callback == null) { - callback = Function.prototype; +function getConverterCallback(value, isBackConvert: boolean) { + let callback = null; + if (isNullOrUndefined(value)) { + callback = value; + } else if (isFunction(value)) { + callback = isBackConvert ? Function.prototype : value; + } else if (isObject(value) && (isFunction(value.toModel) || isFunction(value.toView))) { + callback = (isBackConvert ? value.toModel : value.toView) || Function.prototype; + } else { + callback = value; } - converter.callback = callback; - return converter; + return callback; } export function parseExpression(expressionText: string): ASTExpression {