@pnp/spfx-controls-react
Version:
Reusable React controls for SharePoint Framework solutions
612 lines • 31.2 kB
JavaScript
import { ArrayLiteralNode, Token, ValidFuncNames } from "./FormulaEvaluation.types";
var operatorTypes = ["+", "-", "*", "/", "==", "!=", "<>", ">", "<", ">=", "<=", "&&", "||", "%", "&", "|", "?", ":"];
/** Each token pattern matches a particular token type. The tokenizer looks for matches in this order. */
var patterns = [
[/^\[\$?[a-zA-Z_][a-zA-Z_0-9.]*\]/, "VARIABLE"], // [$variable]
[/^@[a-zA-Z_][a-zA-Z_0-9.]*/, "VARIABLE"], // @variable
[/^[0-9]+(?:\.[0-9]+)?/, "NUMBER"], // Numeric literals
[/^"([^"]*)"/, "STRING"], // Match double-quoted strings
[/^'([^']*)'/, "STRING"], // Match single-quoted strings
[/^\[[^\]]*\]/, "ARRAY"], // Array literals
[new RegExp("^(".concat(ValidFuncNames.join('|'), ")\\(")), "FUNCTION"], // Functions or other words
[/^(true|false)/, "BOOLEAN"], // Boolean literals
[/^\w+/, "WORD"], // Other words, checked against valid variables
[/^&&|^\|\||^==|^<>/, "OPERATOR"], // Operators and special characters (match double first)
[/^[+\-*/<>=%!&|?:,()[\]]/, "OPERATOR"], // Operators and special characters
];
var FormulaEvaluation = /** @class */ (function () {
function FormulaEvaluation(context, webUrlOverride) {
if (context) {
this._meEmail = context.pageContext.user.email;
}
this.webUrl = webUrlOverride || (context === null || context === void 0 ? void 0 : context.pageContext.web.absoluteUrl) || '';
}
/** Evaluates a formula expression and returns the result, with optional context object for variables */
FormulaEvaluation.prototype.evaluate = function (expression, context) {
if (context === void 0) { context = {}; }
context.me = this._meEmail;
context.today = new Date();
var tokens = this.tokenize(expression, context);
var postfix = this.shuntingYard(tokens);
var ast = this.buildAST(postfix);
return this.evaluateASTNode(ast, context);
};
/** Tokenizes an expression into a list of tokens (primatives, operators, variables, function names, arrays etc) */
FormulaEvaluation.prototype.tokenize = function (expression, context) {
if (context === void 0) { context = {}; }
var tokens = [];
var i = 0;
while (i < expression.length) {
var match = null;
// For each pattern, try to match it from the current position in the expression
for (var _i = 0, patterns_1 = patterns; _i < patterns_1.length; _i++) {
var _a = patterns_1[_i], pattern = _a[0], tokenType = _a[1];
var regexResult = pattern.exec(expression.slice(i));
if (regexResult) {
match = regexResult[1] || regexResult[0];
// Unary minus is a special case that we need to
// capture in order to process negative numbers, or
// expressions such as 5 + - 3
if (tokenType === "OPERATOR" && match === "-" && (tokens.length === 0 || tokens[tokens.length - 1].type === "OPERATOR")) {
tokens.push(new Token("UNARY_MINUS", match));
i += match.length + expression.slice(i).indexOf(match);
}
else if (
// String literals, surrounded by single or double quotes
tokenType === "STRING") {
tokens.push(new Token(tokenType, match));
i += match.length + 2;
}
else if (tokenType === "WORD") {
// We only match words if they are a valid variable name
if (context && context[match]) {
tokens.push(new Token("VARIABLE", match));
}
i += match.length;
}
else {
// Otherwise, just add the token to the list
tokens.push(new Token(tokenType, match));
// console.log(`Added token: ${JSON.stringify({tokenType: tokenType, value: match})}`);
i += match.length + expression.slice(i).indexOf(match);
if (tokenType === "FUNCTION")
i -= 1;
}
break;
}
}
if (!match) {
// If no patterns matched, move to the next character
// console.log(`No match found for character: ${expression[i]}`);
i++;
}
}
return tokens;
};
FormulaEvaluation.prototype.shuntingYard = function (tokens) {
/** Stores re-ordered tokens to be returned by this algorithm / method */
var output = [[]];
/** Stores tokens temporarily pushed to a stack to help with re-ordering */
var stack = [[]];
/** Keeps track of parenthesis depth, important for nested expressions and method signatures */
var parenDepth = 0;
for (var _i = 0, tokens_1 = tokens; _i < tokens_1.length; _i++) {
var token = tokens_1[_i];
// Numbers, strings, booleans, words, variables, and arrays are added to the output
if (token.type === "NUMBER" || token.type === "STRING" || token.type === "BOOLEAN" || token.type === "WORD" || token.type === "VARIABLE" || token.type === "ARRAY") {
output[parenDepth].push(token);
// Functions are pushed to the stack
}
else if (token.type === "FUNCTION") {
stack[parenDepth].push(token);
}
else if (token.type === "OPERATOR" || token.type === "UNARY_MINUS") {
// Left parenthesis are pushed to the stack
if (token.value === "(") {
parenDepth++;
stack[parenDepth] = [];
output[parenDepth] = [];
stack[parenDepth].push(token);
}
else if (token.value === ")") {
// When Right parenthesis is found, items are popped from stack to output until left parenthesis is found
while (stack[parenDepth].length > 0 && stack[parenDepth][stack[parenDepth].length - 1].value !== "(") {
output[parenDepth].push(stack[parenDepth].pop());
}
stack[parenDepth].pop(); // pop the left parenthesis
// If the item on top of the stack is a function name, parens were part of method signature,
// pop it to the output
if (stack[parenDepth].length > 0 && stack[parenDepth][stack[parenDepth].length - 1].type === "FUNCTION") {
output[parenDepth].push(stack[parenDepth].pop());
}
// Combine outputs
output[parenDepth - 1] = output[parenDepth - 1].concat(output[parenDepth]);
parenDepth--;
}
else if (token.value === ",") {
// When comma is found, items are popped from stack to output until left parenthesis is found
while (stack[parenDepth].length > 0 && stack[parenDepth][stack[parenDepth].length - 1].value !== "(") {
output[parenDepth].push(stack[parenDepth].pop());
}
// Combine outputs
output[parenDepth - 1] = output[parenDepth - 1].concat(output[parenDepth]);
output[parenDepth] = [];
}
else if (token.value === "?") {
while (stack[parenDepth].length > 0 && stack[parenDepth][stack[parenDepth].length - 1].value !== "(") {
output[parenDepth].push(stack[parenDepth].pop());
}
stack[parenDepth].push(token);
}
else {
// When an operator is found, items are popped from stack to output until an operator with lower precedence is found
var currentTokenPrecedence = this.getPrecedence(token.value.toString());
while (stack[parenDepth].length > 0) {
var topStackIsOperator = stack[parenDepth][stack[parenDepth].length - 1].type === "OPERATOR" || stack[parenDepth][stack[parenDepth].length - 1].type === "UNARY_MINUS";
var topStackPrecedence = this.getPrecedence(stack[parenDepth][stack[parenDepth].length - 1].value.toString());
if (stack[parenDepth][stack[parenDepth].length - 1].value === "(")
break;
if (topStackIsOperator && (currentTokenPrecedence.associativity === "left" ?
topStackPrecedence.precedence <= currentTokenPrecedence.precedence :
topStackPrecedence.precedence < currentTokenPrecedence.precedence)) {
break;
}
output[parenDepth].push(stack[parenDepth].pop());
}
stack[parenDepth].push(token);
}
}
}
// When there are no more tokens to read, pop any remaining tokens from the stack to the output
while (stack[parenDepth].length > 0) {
output[parenDepth].push(stack[parenDepth].pop());
}
// Combine all outputs
var finalOutput = [];
for (var i = 0; i <= parenDepth; i++) {
finalOutput = finalOutput.concat(output[i]);
}
return finalOutput;
};
FormulaEvaluation.prototype.buildAST = function (postfixTokens) {
// Tokens are arranged on a stack/array of node objects
var stack = [];
for (var _i = 0, postfixTokens_1 = postfixTokens; _i < postfixTokens_1.length; _i++) {
var token = postfixTokens_1[_i];
if (token.type === "STRING" || token.type === "NUMBER" || token.type === "BOOLEAN" || token.type === "VARIABLE") {
// Strings, numbers, booleans, function names, and variable names are pushed directly to the stack
stack.push(token);
}
else if (token.type === "UNARY_MINUS") {
// Unary minus has a single operand, we discard the minus token and push an object representing a negative number
var operand = stack.pop();
var numericValue = parseFloat(operand.value);
stack.push({ type: operand.type, value: -numericValue });
}
else if (token.type === "OPERATOR") {
// Operators have two operands, we pop them from the stack and push an object representing the operation
if (operatorTypes.includes(token.value)) {
if (token.value === "?") {
// Ternary operator has three operands, and left and right operators should be top of stack
var colonOperator = stack.pop();
if (colonOperator.operands) {
var rightOperand = colonOperator.operands[1];
var leftOperand = colonOperator.operands[0];
var condition = stack.pop();
stack.push({ type: "FUNCTION", value: "ternary", operands: [condition, leftOperand, rightOperand] });
}
}
else {
var rightOperand = stack.pop();
var leftOperand = stack.pop();
stack.push({ type: token.type, value: token.value, operands: [leftOperand, rightOperand] });
}
}
}
else if (token.type === "ARRAY") {
// At this stage, arrays are represented by a single token with a string value ie "[1,2,3]"
var arrayString = token.value;
// Remove leading and trailing square brackets
arrayString = arrayString.slice(1, -1);
// Split the string by commas and trim whitespace
var arrayElements = arrayString.split(',').map(function (element) { return element.trim(); });
stack.push(new ArrayLiteralNode(arrayElements));
}
else if (token.type === "FUNCTION") {
var arity = this._getFnArity(token.value);
var operands = [];
while (operands.length < arity) {
operands.push(stack.pop());
}
stack.push({ type: token.type, value: token.value, operands: operands.reverse() });
}
}
// At this stage, the stack should contain a single node representing the root of the AST
return stack[0];
};
FormulaEvaluation.prototype.evaluateASTNode = function (node, context) {
var _this = this;
var _a, _b;
if (context === void 0) { context = {}; }
if (!node)
return 0;
// If node is an object with a type and value property, it is an ASTNode and should be evaluated recursively
// otherwise it may actually be an object value that we need to return (as is the case with custom formatting)
if (typeof node === "object" && !(Object.prototype.hasOwnProperty.call(node, 'type') && Object.prototype.hasOwnProperty.call(node, 'value'))) {
return node;
}
// Each element in an ArrayLiteralNode is evaluated recursively
if (node instanceof ArrayLiteralNode) {
var evaluatedElements = node.elements.map(function (element) { return _this.evaluateASTNode(element, context); });
return evaluatedElements;
}
// If node is an actual array, it has likely already been transformed above
if (Array.isArray(node)) {
return node;
}
// Number and string literals are returned as-is
if (typeof node === "number" || typeof node === "string") {
return node;
}
// Nodes with a type of NUMBER are parsed to a number
if (node.type === "NUMBER") {
var numVal = Number(node.value);
if (isNaN(numVal)) {
throw new Error("Invalid number: ".concat(node.value));
}
return numVal;
}
// Nodes with a type of BOOLEAN are parsed to a boolean
if (node.type === "BOOLEAN") {
return node.value === "true" ? 1 : 0;
}
// WORD and STRING nodes are returned as-is
if (node.type === "WORD" || node.type === "STRING") {
return (_a = node.value) === null || _a === void 0 ? void 0 : _a.toString();
}
// VARIABLE nodes are looked up in the context object and returned
if (node.type === "VARIABLE") {
return (_b = context[node.value.replace(/^[[@]?\$?([a-zA-Z_][a-zA-Z_0-9.]*)\]?/, '$1')]) !== null && _b !== void 0 ? _b : null;
}
// OPERATOR nodes have their OPERANDS evaluated recursively, with the operator applied to the results
if (node.type === "OPERATOR" && operatorTypes.includes(node.value) && node.operands) {
var leftValue = this.evaluateASTNode(node.operands[0], context);
var rightValue = this.evaluateASTNode(node.operands[1], context);
// These operators are valid for both string and number operands
switch (node.value) {
case "==": return leftValue === rightValue ? 1 : 0;
case "!=": return leftValue !== rightValue ? 1 : 0;
case "<>": return leftValue !== rightValue ? 1 : 0;
case ">": return leftValue > rightValue ? 1 : 0;
case "<": return leftValue < rightValue ? 1 : 0;
case ">=": return leftValue >= rightValue ? 1 : 0;
case "<=": return leftValue <= rightValue ? 1 : 0;
case "&&": return (leftValue !== 0 && rightValue !== 0) ? 1 : 0;
case "||": return (leftValue !== 0 || rightValue !== 0) ? 1 : 0;
}
if (typeof leftValue === "string" || typeof rightValue === "string") {
// Concatenate strings if either operand is a string
if (node.value === "+") {
var concatString = (leftValue || "").toString() + (rightValue || "").toString();
return concatString;
}
else {
// Throw an error if the operator is not valid for strings
throw new Error("Invalid operation ".concat(node.value, " with string operand."));
}
}
// Both operands will be numbers at this point
switch (node.value) {
case "+": return leftValue + rightValue;
case "-": return leftValue - rightValue;
case "*": return leftValue * rightValue;
case "/": return leftValue / rightValue;
case "%": return leftValue % rightValue;
case "&": return leftValue & rightValue;
case "|": return leftValue | rightValue;
}
}
// Evaluation of function nodes is handled here:
if (node.type === "FUNCTION" && node.operands) {
// Evaluate operands recursively - casting to any here to allow for any type of operand
// eslint-disable-next-line @typescript-eslint/no-explicit-any
var funcArgs = node.operands.map(function (arg) { return _this.evaluateASTNode(arg, context); });
switch (node.value) {
/**
* Logical Functions
*/
case 'if':
case 'ternary': {
var condition = funcArgs[0];
if (condition !== 0) {
return funcArgs[1];
}
else {
return funcArgs[2];
}
}
/**
* Math Functions
*/
case "Number":
return Number(funcArgs[0]);
case "abs":
return Math.abs(funcArgs[0]);
case 'floor':
return Math.floor(funcArgs[0]);
case 'ceiling':
return Math.ceil(funcArgs[0]);
case 'pow': {
var basePow = funcArgs[0];
var exponentPow = funcArgs[1];
return Math.pow(basePow, exponentPow);
}
case 'cos': {
var angleCos = funcArgs[0];
return Math.cos(angleCos);
}
case 'sin': {
var angleSin = funcArgs[0];
return Math.sin(angleSin);
}
/**
* String Functions
*/
case "toString":
return funcArgs[0].toString();
case 'lastIndexOf': {
var mainStrLastIndexOf = funcArgs[0];
var searchStrLastIndexOf = funcArgs[1];
return mainStrLastIndexOf.lastIndexOf(searchStrLastIndexOf);
}
case 'join': {
var arrayToJoin = node.operands[0].evaluate();
var separator = funcArgs[1];
return arrayToJoin.join(separator);
}
case 'substring': {
var mainStrSubstring = funcArgs[0] || '';
var start = funcArgs[1] || 0;
var end = funcArgs[2] || mainStrSubstring.length;
return mainStrSubstring.substr(start, end);
}
case 'toUpperCase': {
var strToUpper = funcArgs[0] || '';
return strToUpper.toUpperCase();
}
case 'toLowerCase': {
var strToLower = funcArgs[0] || '';
return strToLower.toLowerCase();
}
case 'startsWith': {
var mainStrStartsWith = funcArgs[0];
var searchStrStartsWith = funcArgs[1];
return mainStrStartsWith.startsWith(searchStrStartsWith);
}
case 'endsWith': {
var mainStrEndsWith = funcArgs[0];
var searchStrEndsWith = funcArgs[1];
return mainStrEndsWith.endsWith(searchStrEndsWith);
}
case 'replace': {
var mainStrReplace = funcArgs[0];
var searchStrReplace = funcArgs[1];
var replaceStr = funcArgs[2];
return mainStrReplace.replace(searchStrReplace, replaceStr);
}
case 'replaceAll': {
var mainStrReplaceAll = funcArgs[0];
var searchStrReplaceAll = funcArgs[1];
var replaceAllStr = funcArgs[2];
// Using a global regex to simulate replaceAll behavior
var globalRegex = new RegExp(searchStrReplaceAll, 'g');
return mainStrReplaceAll.replace(globalRegex, replaceAllStr);
}
case 'padStart': {
var mainStrPadStart = funcArgs[0];
var lengthPadStart = funcArgs[1];
var padStrStart = funcArgs[2];
return mainStrPadStart.padStart(lengthPadStart, padStrStart);
}
case 'padEnd': {
var mainStrPadEnd = funcArgs[0];
var lengthPadEnd = funcArgs[1];
var padStrEnd = funcArgs[2];
return mainStrPadEnd.padEnd(lengthPadEnd, padStrEnd);
}
case 'split': {
var mainStrSplit = funcArgs[0];
var delimiterSplit = funcArgs[1];
return mainStrSplit.split(delimiterSplit);
}
/**
* Date Functions
*/
case 'toDate': {
var dateStr = funcArgs[0];
return new Date(dateStr);
}
case 'toDateString': {
var dateToDateString = new Date(funcArgs[0]);
return dateToDateString.toDateString();
}
case "toLocaleString": {
var dateToLocaleString = new Date(funcArgs[0]);
return dateToLocaleString.toLocaleString();
}
case "toLocaleDateString": {
var dateToLocaleDateString = new Date(funcArgs[0]);
return dateToLocaleDateString.toLocaleDateString();
}
case "toLocaleTimeString": {
var dateToLocaleTimeString = new Date(funcArgs[0]);
return dateToLocaleTimeString.toLocaleTimeString();
}
case 'getDate': {
var dateStrGetDate = funcArgs[0];
return new Date(dateStrGetDate).getDate();
}
case 'getMonth': {
var dateStrGetMonth = funcArgs[0];
return new Date(dateStrGetMonth).getMonth();
}
case 'getYear': {
var dateStrGetYear = funcArgs[0];
return new Date(dateStrGetYear).getFullYear();
}
case 'addDays': {
var dateStrAddDays = funcArgs[0];
var daysToAdd = funcArgs[1];
var dateAddDays = new Date(dateStrAddDays);
dateAddDays.setDate(dateAddDays.getDate() + daysToAdd);
return dateAddDays;
}
case 'addMinutes': {
var dateStrAddMinutes = funcArgs[0];
var minutesToAdd = funcArgs[1];
var dateAddMinutes = new Date(dateStrAddMinutes);
dateAddMinutes.setMinutes(dateAddMinutes.getMinutes() + minutesToAdd);
return dateAddMinutes;
}
/**
* SharePoint Functions
*/
case 'getUserImage': {
var userEmail = funcArgs[0];
var userImage = this._getUserImageUrl(userEmail);
return userImage;
}
case 'getThumbnailImage': {
var imageUrl = funcArgs[0];
var thumbnailImage = this._getSharePointThumbnailUrl(imageUrl);
return thumbnailImage;
}
/**
* Array Functions
*/
case "indexOf": {
var array = funcArgs[0];
var operand = funcArgs[1];
if (Array.isArray(array)) {
return array.indexOf(operand);
}
else if (typeof array === 'string') {
return array.indexOf(operand);
}
return -1; // Default to -1 if not found.
}
case "length": {
var array = funcArgs[0];
if (array instanceof ArrayLiteralNode) {
// treat as array literal
var value = array.evaluate();
return value.length;
}
else {
// treat as char Array
var value = this.evaluateASTNode(array, context);
return value.toString().length;
}
}
case 'appendTo': {
var mainArrayAppend = node.operands[0].evaluate();
var elementToAppend = funcArgs[1];
mainArrayAppend.push(elementToAppend);
return mainArrayAppend;
}
case 'removeFrom': {
var mainArrayRemove = node.operands[0].evaluate();
var elementToRemove = funcArgs[1];
var indexToRemove = mainArrayRemove.indexOf(elementToRemove);
if (indexToRemove !== -1) {
mainArrayRemove.splice(indexToRemove, 1);
}
return mainArrayRemove;
}
case 'loopIndex':
return 0; // This should ideally return the current loop index in context but is not implemented yet
}
}
return 0; // Default fallback
};
FormulaEvaluation.prototype.validate = function (expression) {
var validFunctionRegex = "(".concat(ValidFuncNames.map(function (fn) { return "".concat(fn, "\\("); }).join('|'), ")");
var pattern = new RegExp("^(?:@\\w+|\\[\\$?[\\w+.]\\]|\\d+(?:\\.\\d+)?|\"(?:[^\"]*)\"|'(?:[^']*)'|".concat(validFunctionRegex, "|[+\\-*/<>=%!&|?:,()\\[\\]]|\\?|:)"));
/* Explanation -
/@\\w+/ matches variables specified by the form @variableName.
/\\[\\$?\\w+\\/] matches variables specified by the forms [variableName] and [$variableName].
/\\d+(?:\\.\\d+)?/ matches numbers, including decimal numbers.
/"(?:[^"]*)"/ and /'(?:[^']*)'/ match string literals in double and single quotes, respectively.
/${validFunctionRegex}/ matches valid function names.
/\\?/ matches the ternary operator ?.
/:/ matches the colon :.
/[+\\-*/ //<>=%!&|?:,()\\[\\]]/ matches operators.
return pattern.test(expression);
};
/** Returns a precedence value for a token or operator */
FormulaEvaluation.prototype.getPrecedence = function (op) {
var _a;
// If the operator is a valid function name, return a high precedence value
if (ValidFuncNames.indexOf(op) >= 0)
return { precedence: 7, associativity: "left" };
// Otherwise, return the precedence value for the operator
var precedence = {
"+": { precedence: 4, associativity: "left" },
"-": { precedence: 4, associativity: "left" },
"*": { precedence: 5, associativity: "left" },
"/": { precedence: 5, associativity: "left" },
"%": { precedence: 5, associativity: "left" },
">": { precedence: 3, associativity: "left" },
"<": { precedence: 3, associativity: "left" },
"==": { precedence: 3, associativity: "left" },
"!=": { precedence: 3, associativity: "left" },
"<>": { precedence: 3, associativity: "left" },
">=": { precedence: 3, associativity: "left" },
"<=": { precedence: 3, associativity: "left" },
"&&": { precedence: 2, associativity: "left" },
"||": { precedence: 1, associativity: "left" },
"?": { precedence: 6, associativity: "left" },
":": { precedence: 6, associativity: "left" },
",": { precedence: 0, associativity: "left" },
};
return (_a = precedence[op]) !== null && _a !== void 0 ? _a : { precedence: 8, associativity: "left" };
};
FormulaEvaluation.prototype._getFnArity = function (fnName) {
switch (fnName) {
case "if":
case "substring":
case "replace":
case "replaceAll":
case "padStart":
case "padEnd":
case "ternary":
return 3;
case "pow":
case "indexOf":
case "lastIndexOf":
case "join":
case "startsWith":
case "endsWith":
case "split":
case "addDays":
case "addMinutes":
case "appendTo":
case "removeFrom":
return 2;
default:
return 1;
}
};
FormulaEvaluation.prototype._getSharePointThumbnailUrl = function (imageUrl) {
var filename = imageUrl.split('/').pop();
var url = imageUrl.replace(filename, '');
var _a = filename.split('.'), filenameNoExt = _a[0], ext = _a[1];
return "".concat(url, "_t/").concat(filenameNoExt, "_").concat(ext, ".jpg");
};
FormulaEvaluation.prototype._getUserImageUrl = function (userEmail) {
return "".concat(this.webUrl, "/_layouts/15/userphoto.aspx?size=L&accountname=").concat(userEmail);
};
return FormulaEvaluation;
}());
export { FormulaEvaluation };
//# sourceMappingURL=FormulaEvaluation.js.map