diff --git a/.gitignore b/.gitignore index 900af3ede..fa44b8540 100644 --- a/.gitignore +++ b/.gitignore @@ -35,7 +35,11 @@ obj *.tgz # VSCode -.vscode +.vscode/* + +# Included VSCode files +!.vscode/example-tasks.json +!.vscode/example-launch.json # Documentation docs/documentation/site diff --git a/.vscode/example-launch.json b/.vscode/example-launch.json new file mode 100644 index 000000000..14a70321b --- /dev/null +++ b/.vscode/example-launch.json @@ -0,0 +1,44 @@ +{ + /** + * Populate and rename this file to launch.json to configure debugging + */ + "version": "0.2.0", + "configurations": [ + { + "name": "Hosted workbench (chrome)", + "type": "chrome", + "request": "launch", + "url": "https://enter-your-SharePoint-site.sharepoint.com/sites/mySite/_layouts/15/workbench.aspx", + "webRoot": "${workspaceRoot}", + "sourceMaps": true, + "sourceMapPathOverrides": { + "webpack:///.././src/*": "${webRoot}/src/*", + "webpack:///../../../src/*": "${webRoot}/src/*", + "webpack:///../../../../src/*": "${webRoot}/src/*", + "webpack:///../../../../../src/*": "${webRoot}/src/*" + }, + "preLaunchTask": "npm: serve", + "runtimeArgs": [ + "--remote-debugging-port=9222", + ] + }, + { + "name": "Hosted workbench (edge)", + "type": "edge", + "request": "launch", + "url": "https://enter-your-SharePoint-site.sharepoint.com/sites/mySite/_layouts/15/workbench.aspx", + "webRoot": "${workspaceRoot}", + "sourceMaps": true, + "sourceMapPathOverrides": { + "webpack:///.././src/*": "${webRoot}/src/*", + "webpack:///../../../src/*": "${webRoot}/src/*", + "webpack:///../../../../src/*": "${webRoot}/src/*", + "webpack:///../../../../../src/*": "${webRoot}/src/*" + }, + "preLaunchTask": "npm: serve", + "runtimeArgs": [ + "--remote-debugging-port=9222", + ] + }, + ] + } \ No newline at end of file diff --git a/.vscode/example-tasks.json b/.vscode/example-tasks.json new file mode 100644 index 000000000..bb633d756 --- /dev/null +++ b/.vscode/example-tasks.json @@ -0,0 +1,30 @@ +{ + /** + * Populate and rename this file to launch.json to configure debugging + */ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "serve", + "isBackground": true, + "problemMatcher": { + "owner": "custom", + "pattern": { + "regexp": "." + }, + "background": { + "activeOnStart": true, + "beginsPattern": "Starting 'bundle'", + "endsPattern": "\\[\\sFinished\\s\\]" + } + }, + "label": "npm: serve", + "detail": "gulp bundle --custom-serve --max_old_space_size=4096 && fast-serve", + "group": { + "kind": "build", + "isDefault": true + } + }, + ] +} \ No newline at end of file diff --git a/src/common/SPEntities.ts b/src/common/SPEntities.ts index 531c89acc..4d4734c02 100644 --- a/src/common/SPEntities.ts +++ b/src/common/SPEntities.ts @@ -56,6 +56,11 @@ export interface ISPField { LookupDisplayUrl?: string; TypeAsString?: string; ResultType?: string; + ValidationFormula?: string; + ValidationMessage?: string; + MinimumValue?: number; + MaximumValue?: number; + CurrencyLocaleId?: number; } /** diff --git a/src/common/utilities/CustomFormatting.ts b/src/common/utilities/CustomFormatting.ts new file mode 100644 index 000000000..db3438dcc --- /dev/null +++ b/src/common/utilities/CustomFormatting.ts @@ -0,0 +1,163 @@ +import * as React from "react"; +import { Icon } from "@fluentui/react/lib/Icon"; +import { FormulaEvaluation } from "./FormulaEvaluation"; +import { ASTNode, Context } from "./FormulaEvaluation.types"; +import { ICustomFormattingExpressionNode, ICustomFormattingNode } from "./ICustomFormatting"; + +type CustomFormatResult = string | number | boolean | JSX.Element | ICustomFormattingNode; + +/** + * A class that provides helper methods for custom formatting + * See: https://learn.microsoft.com/en-us/sharepoint/dev/declarative-customization/formatting-syntax-reference + */ +export default class CustomFormattingHelper { + + private _formulaEvaluator: FormulaEvaluation; + + /** + * Custom Formatting Helper / Renderer + * @param formulaEvaluator An instance of FormulaEvaluation used for evaluating expressions in custom formatting + */ + constructor(formulaEvaluator: FormulaEvaluation) { + this._formulaEvaluator = formulaEvaluator; + } + + /** + * The Formula Evaluator expects an ASTNode to be passed to it for evaluation. This method converts expressions + * described by the interface ICustomFormattingExpressionNode to ASTNodes. + * @param node An ICustomFormattingExpressionNode to be converted to an ASTNode + */ + private convertCustomFormatExpressionNodes = (node: ICustomFormattingExpressionNode | string | number | boolean): ASTNode => { + if (typeof node !== "object") { + switch (typeof node) { + case "string": + return { type: "string", value: node }; + case "number": + return { type: "number", value: node }; + case "boolean": + return { type: "booelan", value: node ? 1 : 0 }; + } + } + const operator = node.operator; + const operands = node.operands.map(o => this.convertCustomFormatExpressionNodes(o)); + return { type: "operator", value: operator, operands }; + } + + /** + * Given a single custom formatting expression, node or element, this method evaluates the expression and returns the result + * @param content An object, expression or literal value to be evaluated + * @param context A context object containing values / variables to be used in the evaluation + * @returns + */ + private evaluateCustomFormatContent = (content: ICustomFormattingExpressionNode | ICustomFormattingNode | string | number | boolean, context: Context): CustomFormatResult => { + + // If content is a string or number, it is a literal value and should be returned as-is + if ((typeof content === "string" && content.charAt(0) !== "=") || typeof content === "number") return content; + + // If content is a string beginning with '=' it is a formula/expression, and should be evaluated + if (typeof content === "string" && content.charAt(0) === "=") { + const result = this._formulaEvaluator.evaluate(content.substring(1), context); + return result as CustomFormatResult; + } + + // If content is an object, it is either further custom formatting described by an ICustomFormattingNode, + // or an expression to be evaluated - as described by an ICustomFormattingExpressionNode + + if (typeof content === "object") { + + if (Object.prototype.hasOwnProperty.call(content, "elmType")) { + + // Custom Formatting Content + return this.renderCustomFormatContent(content as ICustomFormattingNode, context); + + } else if (Object.prototype.hasOwnProperty.call(content, "operator")) { + + // Expression to be evaluated + const astNode = this.convertCustomFormatExpressionNodes(content as ICustomFormattingExpressionNode); + const result = this._formulaEvaluator.evaluateASTNode(astNode, context); + if (typeof result === "object" && Object.prototype.hasOwnProperty.call(result, "elmType")) { + return this.renderCustomFormatContent(result as ICustomFormattingNode, context); + } + return result as CustomFormatResult; + + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public renderCustomFormatContent = (node: ICustomFormattingNode, context: Context, rootEl: boolean = false): JSX.Element | string | number => { + + // We don't want attempts to render custom format content to kill the component or web part, + // so we wrap the entire method in a try/catch block, log errors and return null if an error occurs + try { + + // If node is a string or number, it is a literal value and should be returned as-is + if (typeof node === "string" || typeof node === "number") return node; + + // Custom formatting nodes / elements may have a txtContent property, which represents the inner + // content of a HTML element. This can be a string literal, or another expression to be evaluated: + let textContent: CustomFormatResult | undefined; + if (node.txtContent) { + textContent = this.evaluateCustomFormatContent(node.txtContent, context); + } + + // Custom formatting nodes / elements may have a style property, which contains the style rules + // to be applied to the resulting HTML element. Rule values can be string literals or another expression + // to be evaluated: + const styleProperties: React.CSSProperties = {}; + if (node.style) { + for (const styleAttribute in node.style) { + if (node.style[styleAttribute]) { + styleProperties[styleAttribute] = this.evaluateCustomFormatContent(node.style[styleAttribute], context) as string; + } + } + } + + // Custom formatting nodes / elements may have an attributes property, which represents the HTML attributes + // to be applied to the resulting HTML element. Attribute values can be string literals or another expression + // to be evaluated: + const attributes = {} as Record; + if (node.attributes) { + for (const attribute in node.attributes) { + if (node.attributes[attribute]) { + let attributeName = attribute; + + // Because we're using React to render the HTML content, we need to rename the 'class' attribute + if (attributeName === "class") attributeName = "className"; + + // Evaluation + attributes[attributeName] = this.evaluateCustomFormatContent(node.attributes[attribute], context) as string; + + // Add the 'sp-field-customFormatter' class to the root element + if (attributeName === "className" && rootEl) { + attributes[attributeName] = `${attributes[attributeName]} sp-field-customFormatter`; + } + } + } + } + + // Custom formatting nodes / elements may have children. These are likely to be further custom formatting + let children: (CustomFormatResult)[] = []; + + // If the node has an iconName property, we'll render an Icon component as the first child. + // SharePoint uses CSS to apply the icon in a ::before rule, but we can't count on the global selector for iconName + // being present on the page, so we'll add it as a child instead: + if (attributes.iconName) { + const icon = React.createElement(Icon, { iconName: attributes.iconName }); + children.push(icon); + } + + // Each child object is evaluated recursively and added to the children array + if (node.children) { + children = node.children.map(c => this.evaluateCustomFormatContent(c, context)); + } + + // The resulting HTML element is returned to the callee using React.createElement + const el = React.createElement(node.elmType, { style: styleProperties, ...attributes }, textContent, ...children); + return el; + } catch (error) { + console.error('Unable to render custom formatted content', error); + return null; + } + } +} \ No newline at end of file diff --git a/src/common/utilities/FormulaEvaluation.ts b/src/common/utilities/FormulaEvaluation.ts new file mode 100644 index 000000000..3b9acaa48 --- /dev/null +++ b/src/common/utilities/FormulaEvaluation.ts @@ -0,0 +1,659 @@ +import { IContext } from "../Interfaces"; +import { ASTNode, ArrayLiteralNode, ArrayNodeValue, Context, Token, TokenType, ValidFuncNames } from "./FormulaEvaluation.types"; + +const operatorTypes = ["+", "-", "*", "/", "==", "!=", "<>", ">", "<", ">=", "<=", "&&", "||", "%", "&", "|", "?", ":"]; + +/** Each token pattern matches a particular token type. The tokenizer looks for matches in this order. */ +const patterns: [RegExp, TokenType][] = [ + [/^\[\$?[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(`^(${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 +]; + +export class FormulaEvaluation { + private webUrl: string; + private _meEmail: string; + + constructor(context?: IContext, webUrlOverride?: string) { + if (context) { + this._meEmail = context.pageContext.user.email; + } + this.webUrl = webUrlOverride || context?.pageContext.web.absoluteUrl || ''; + } + + /** Evaluates a formula expression and returns the result, with optional context object for variables */ + public evaluate(expression: string, context: Context = {}): boolean | string | number | ArrayNodeValue | object { + context.me = this._meEmail; + context.today = new Date(); + const tokens: Token[] = this.tokenize(expression, context); + const postfix: Token[] = this.shuntingYard(tokens); + const ast: ASTNode = this.buildAST(postfix); + return this.evaluateASTNode(ast, context); + } + + /** Tokenizes an expression into a list of tokens (primatives, operators, variables, function names, arrays etc) */ + public tokenize(expression: string, context: Context = {}): Token[] { + + const tokens: Token[] = []; + let i = 0; + + while (i < expression.length) { + let match: string | null = null; + + // For each pattern, try to match it from the current position in the expression + for (const [pattern, tokenType] of patterns) { + const 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; + } + + public shuntingYard(tokens: Token[]): Token[] { + + /** Stores re-ordered tokens to be returned by this algorithm / method */ + const output: Token[][] = [[]]; + + /** Stores tokens temporarily pushed to a stack to help with re-ordering */ + const stack: Token[][] = [[]]; + + /** Keeps track of parenthesis depth, important for nested expressions and method signatures */ + let parenDepth = 0; + + for (const token of tokens) { + // 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() as Token); + } + 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() as Token); + } + // 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() as Token); + } + // 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() as Token); + } + 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 + const currentTokenPrecedence = this.getPrecedence(token.value.toString()); + + while (stack[parenDepth].length > 0) { + const topStackIsOperator = stack[parenDepth][stack[parenDepth].length - 1].type === "OPERATOR" || stack[parenDepth][stack[parenDepth].length - 1].type === "UNARY_MINUS"; + const 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() as Token); + } + + 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() as Token); + } + + // Combine all outputs + let finalOutput: Token[] = []; + for (let i = 0; i <= parenDepth; i++) { + finalOutput = finalOutput.concat(output[i]); + } + + return finalOutput; + } + + public buildAST(postfixTokens: Token[]): ASTNode { + + // Tokens are arranged on a stack/array of node objects + const stack: (Token | ASTNode | ArrayLiteralNode)[] = []; + for (const token of postfixTokens) { + 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 + const operand = stack.pop() as ASTNode; + const numericValue = parseFloat(operand.value as string); + 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 as string)) { + if (token.value === "?") { + // Ternary operator has three operands, and left and right operators should be top of stack + const colonOperator = stack.pop() as ASTNode; + if (colonOperator.operands) { + const rightOperand = colonOperator.operands[1]; + const leftOperand = colonOperator.operands[0]; + const condition = stack.pop() as ASTNode; + stack.push({ type: "FUNCTION", value: "ternary", operands: [condition, leftOperand, rightOperand] }); + } + } else { + const rightOperand = stack.pop() as ASTNode; + const leftOperand = stack.pop() as ASTNode; + 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]" + let arrayString = token.value as string; + // Remove leading and trailing square brackets + arrayString = arrayString.slice(1, -1); + // Split the string by commas and trim whitespace + const arrayElements = arrayString.split(',').map(element => element.trim()); + stack.push(new ArrayLiteralNode(arrayElements)); + } else if (token.type === "FUNCTION") { + const arity = this._getFnArity(token.value as string); + const operands: ASTNode[] = []; + while (operands.length < arity) { + operands.push(stack.pop() as ASTNode); + } + 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] as ASTNode; + } + + public evaluateASTNode(node: ASTNode | ArrayLiteralNode | ArrayNodeValue | string | number, context: Context = {}): + boolean | number | string | ArrayNodeValue | object { + + 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) { + const evaluatedElements = (node as ArrayLiteralNode).elements.map(element => 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") { + const numVal = Number(node.value); + if (isNaN(numVal)) { + throw new Error(`Invalid number: ${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 node.value?.toString(); + } + + // VARIABLE nodes are looked up in the context object and returned + if (node.type === "VARIABLE") { + return context[(node.value as string).replace(/^[[@]?\$?([a-zA-Z_][a-zA-Z_0-9.]*)\]?/, '$1')] ?? null; + } + + // OPERATOR nodes have their OPERANDS evaluated recursively, with the operator applied to the results + if (node.type === "OPERATOR" && operatorTypes.includes(node.value as string) && node.operands) { + + const leftValue = this.evaluateASTNode(node.operands[0], context) as string | number; + const rightValue = this.evaluateASTNode(node.operands[1], context) as string | number; + + // 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 === "+") { + const concatString: string = (leftValue || "").toString() + (rightValue || "").toString(); + return concatString; + } else { + // Throw an error if the operator is not valid for strings + throw new Error(`Invalid operation ${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 + const funcArgs = node.operands.map(arg => this.evaluateASTNode(arg, context)) as any[]; + + switch (node.value) { + + /** + * Logical Functions + */ + + case 'if': + case 'ternary': { + const 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': { + const basePow = funcArgs[0]; + const exponentPow = funcArgs[1]; + return Math.pow(basePow, exponentPow); + } + case 'cos': { + const angleCos = funcArgs[0]; + return Math.cos(angleCos); + } + case 'sin': { + const angleSin = funcArgs[0]; + return Math.sin(angleSin); + } + + /** + * String Functions + */ + + case "toString": + return funcArgs[0].toString(); + case 'lastIndexOf': { + const mainStrLastIndexOf = funcArgs[0]; + const searchStrLastIndexOf = funcArgs[1]; + return mainStrLastIndexOf.lastIndexOf(searchStrLastIndexOf); + } + case 'join': { + const arrayToJoin = (node.operands[0] as ArrayLiteralNode).evaluate(); + const separator = funcArgs[1]; + return arrayToJoin.join(separator); + } + case 'substring': { + const mainStrSubstring = funcArgs[0] || ''; + const start = funcArgs[1] || 0; + const end = funcArgs[2] || mainStrSubstring.length; + return mainStrSubstring.substr(start, end); + } + case 'toUpperCase': { + const strToUpper = funcArgs[0] || ''; + return strToUpper.toUpperCase(); + } + case 'toLowerCase': { + const strToLower = funcArgs[0] || ''; + return strToLower.toLowerCase(); + } + case 'startsWith': { + const mainStrStartsWith = funcArgs[0]; + const searchStrStartsWith = funcArgs[1]; + return mainStrStartsWith.startsWith(searchStrStartsWith); + } + case 'endsWith': { + const mainStrEndsWith = funcArgs[0]; + const searchStrEndsWith = funcArgs[1]; + return mainStrEndsWith.endsWith(searchStrEndsWith); + } + case 'replace': { + const mainStrReplace = funcArgs[0]; + const searchStrReplace = funcArgs[1]; + const replaceStr = funcArgs[2]; + return mainStrReplace.replace(searchStrReplace, replaceStr); + } + case 'replaceAll': { + const mainStrReplaceAll = funcArgs[0]; + const searchStrReplaceAll = funcArgs[1]; + const replaceAllStr = funcArgs[2]; + // Using a global regex to simulate replaceAll behavior + const globalRegex = new RegExp(searchStrReplaceAll, 'g'); + return mainStrReplaceAll.replace(globalRegex, replaceAllStr); + } + case 'padStart': { + const mainStrPadStart = funcArgs[0]; + const lengthPadStart = funcArgs[1]; + const padStrStart = funcArgs[2]; + return mainStrPadStart.padStart(lengthPadStart, padStrStart); + } + case 'padEnd': { + const mainStrPadEnd = funcArgs[0]; + const lengthPadEnd = funcArgs[1]; + const padStrEnd = funcArgs[2]; + return mainStrPadEnd.padEnd(lengthPadEnd, padStrEnd); + } + case 'split': { + const mainStrSplit = funcArgs[0]; + const delimiterSplit = funcArgs[1]; + return mainStrSplit.split(delimiterSplit); + } + + /** + * Date Functions + */ + + case 'toDate': { + const dateStr = funcArgs[0]; + return new Date(dateStr); + } + case 'toDateString': { + const dateToDateString = new Date(funcArgs[0]); + return dateToDateString.toDateString(); + } + case "toLocaleString": { + const dateToLocaleString = new Date(funcArgs[0]); + return dateToLocaleString.toLocaleString(); + } + case "toLocaleDateString": { + const dateToLocaleDateString = new Date(funcArgs[0]); + return dateToLocaleDateString.toLocaleDateString(); + } + case "toLocaleTimeString": { + const dateToLocaleTimeString = new Date(funcArgs[0]); + return dateToLocaleTimeString.toLocaleTimeString(); + } + case 'getDate': { + const dateStrGetDate = funcArgs[0]; + return new Date(dateStrGetDate).getDate(); + } + case 'getMonth': { + const dateStrGetMonth = funcArgs[0]; + return new Date(dateStrGetMonth).getMonth(); + } + case 'getYear': { + const dateStrGetYear = funcArgs[0]; + return new Date(dateStrGetYear).getFullYear(); + } + case 'addDays': { + const dateStrAddDays = funcArgs[0]; + const daysToAdd = funcArgs[1]; + const dateAddDays = new Date(dateStrAddDays); + dateAddDays.setDate(dateAddDays.getDate() + daysToAdd); + return dateAddDays; + } + case 'addMinutes': { + const dateStrAddMinutes = funcArgs[0]; + const minutesToAdd = funcArgs[1]; + const dateAddMinutes = new Date(dateStrAddMinutes); + dateAddMinutes.setMinutes(dateAddMinutes.getMinutes() + minutesToAdd); + return dateAddMinutes; + } + + /** + * SharePoint Functions + */ + + case 'getUserImage': { + const userEmail = funcArgs[0]; + const userImage = this._getUserImageUrl(userEmail); + return userImage; + } + case 'getThumbnailImage': { + const imageUrl = funcArgs[0]; + const thumbnailImage = this._getSharePointThumbnailUrl(imageUrl); + return thumbnailImage; + } + + /** + * Array Functions + */ + + case "indexOf": { + const array = funcArgs[0]; + const 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": { + const array = funcArgs[0]; + if (array instanceof ArrayLiteralNode) { + // treat as array literal + const value = array.evaluate(); + return value.length; + } + else { + // treat as char Array + const value = this.evaluateASTNode(array, context); + return value.toString().length; + } + } + case 'appendTo': { + const mainArrayAppend = (node.operands[0] as ArrayLiteralNode).evaluate(); + const elementToAppend = funcArgs[1]; + mainArrayAppend.push(elementToAppend); + return mainArrayAppend; + } + case 'removeFrom': { + const mainArrayRemove = (node.operands[0] as ArrayLiteralNode).evaluate(); + const elementToRemove = funcArgs[1]; + const 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 + } + + public validate(expression: string): boolean { + const validFunctionRegex = `(${ValidFuncNames.map(fn => `${fn}\\(`).join('|')})`; + const pattern = new RegExp(`^(?:@\\w+|\\[\\$?[\\w+.]\\]|\\d+(?:\\.\\d+)?|"(?:[^"]*)"|'(?:[^']*)'|${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 */ + private getPrecedence(op: string): { precedence: number, associativity: "left" | "right" } { + + // 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 + const precedence: { [key: string]: { precedence: number, associativity: "left" | "right" } } = { + "+": { 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 precedence[op] ?? { precedence: 8, associativity: "left" }; + } + + private _getFnArity(fnName: string): number { + 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; + } + } + + private _getSharePointThumbnailUrl(imageUrl: string): string { + const filename = imageUrl.split('/').pop(); + const url = imageUrl.replace(filename, ''); + const [filenameNoExt, ext] = filename.split('.'); + return `${url}_t/${filenameNoExt}_${ext}.jpg`; + } + private _getUserImageUrl(userEmail: string): string { + return `${this.webUrl}/_layouts/15/userphoto.aspx?size=L&accountname=${userEmail}` + } +} + + diff --git a/src/common/utilities/FormulaEvaluation.types.ts b/src/common/utilities/FormulaEvaluation.types.ts new file mode 100644 index 000000000..7af1c0555 --- /dev/null +++ b/src/common/utilities/FormulaEvaluation.types.ts @@ -0,0 +1,97 @@ +export type TokenType = "FUNCTION" | "STRING" | "NUMBER" | "UNARY_MINUS" | "BOOLEAN" | "WORD" | "OPERATOR" | "ARRAY" | "VARIABLE"; + +export class Token { + type: TokenType; + value: string | number; + + constructor(tokenType: TokenType, value: string | number) { + this.type = tokenType; + this.value = value; + } + + toString(): string { + return `${this.type}: ${this.value}`; + } +} + +export type ArrayNodeValue = (string | number | ArrayNodeValue)[]; +export class ArrayLiteralNode { + elements: ArrayNodeValue; + + constructor(elements: ArrayNodeValue) { + this.elements = elements; // Store array elements + } + + evaluate(): ArrayNodeValue { + // Evaluate array elements and return the array + const evaluatedElements = this.elements.map((element) => { + if (element instanceof ArrayLiteralNode) { + return element.evaluate(); + } else { + if ( + typeof element === "string" && + ( + (element.startsWith("'") && element.endsWith("'")) || + (element.startsWith('"') && element.endsWith('"')) + ) + ) { + return element.slice(1, -1); + } else { + return element; + } + } + }); + return evaluatedElements; + } +} + +export type ASTNode = { + type: string; + value?: string | number; + operands?: (ASTNode | ArrayLiteralNode)[]; +}; + +export type Context = { [key: string]: boolean | number | object | string | undefined }; + +export const ValidFuncNames = [ + "if", + "ternary", + "Number", + "abs", + "floor", + "ceiling", + "pow", + "cos", + "sin", + "indexOf", + "lastIndexOf", + "toString", + "join", + "substring", + "toUpperCase", + "toLowerCase", + "startsWith", + "endsWith", + "replaceAll", + "replace", + "padStart", + "padEnd", + "split", + "toDateString", + "toDate", + "toLocaleString", + "toLocaleDateString", + "toLocaleTimeString", + "getDate", + "getMonth", + "getYear", + "addDays", + "addMinutes", + "getUserImage", + "getThumbnailImage", + "indexOf", + "length", + "appendTo", + "removeFrom", + "loopIndex" +]; \ No newline at end of file diff --git a/src/common/utilities/ICustomFormatting.ts b/src/common/utilities/ICustomFormatting.ts new file mode 100644 index 000000000..119af53f9 --- /dev/null +++ b/src/common/utilities/ICustomFormatting.ts @@ -0,0 +1,30 @@ +import { CSSProperties } from "react"; + +export interface ICustomFormattingExpressionNode { + operator: string; + operands: (string | number | ICustomFormattingExpressionNode)[]; +} + +export interface ICustomFormattingNode { + elmType: keyof HTMLElementTagNameMap; + iconName: string; + style: CSSProperties; + attributes?: { + [key: string]: string; + }; + children?: ICustomFormattingNode[]; + txtContent?: string; +} + +export interface ICustomFormattingBodySection { + displayname: string; + fields: string[]; +} + +export interface ICustomFormatting { + headerJSONFormatter: ICustomFormattingNode; + bodyJSONFormatter: { + sections: ICustomFormattingBodySection[]; + }; + footerJSONFormatter: ICustomFormattingNode; +} \ No newline at end of file diff --git a/src/controls/dynamicForm/DynamicForm.module.scss b/src/controls/dynamicForm/DynamicForm.module.scss index db54b38a4..f5f9a8020 100644 --- a/src/controls/dynamicForm/DynamicForm.module.scss +++ b/src/controls/dynamicForm/DynamicForm.module.scss @@ -152,3 +152,54 @@ display: flex; margin: 10px 0px; } + +h2.sectionTitle { + color: #000000; + font-weight: 600; + font-size: 16px; + margin-top: 6px; + margin-bottom: 12px; + clear: both; +} +.sectionFormFields { + display: flex; + flex-wrap: wrap; +} +.sectionFormField { + @media (max-width: 1920px) { + min-width: 244px; + max-width: 244px; + } + @media (max-width: 1366px) { + min-width: 248px; + max-width: 248px; + margin-right: 44px; + } + @media (max-width: 1024px) { + min-width: 272px; + max-width: 272px; + } + @media (max-width: 640px) { + min-width: 432px; + max-width: 432px; + } + @media (max-width: 480px) { + width: 90%; + } +} +.sectionLine { + width: 100%; + border-top: 1px solid #edebe9; + border-bottom-width: 0; + border-left-width: 0; + border-right-width: 0; + clear: both; +} + +:global { + .sp-field-customFormatter { + min-height: inherit; + display: flex; + align-items: center; + } +} \ No newline at end of file diff --git a/src/controls/dynamicForm/DynamicForm.tsx b/src/controls/dynamicForm/DynamicForm.tsx index 846ae2d2c..0a63879a3 100644 --- a/src/controls/dynamicForm/DynamicForm.tsx +++ b/src/controls/dynamicForm/DynamicForm.tsx @@ -1,38 +1,50 @@ /* eslint-disable @microsoft/spfx/no-async-await */ -import { SPHttpClient } from "@microsoft/sp-http"; -import { IInstalledLanguageInfo, sp } from "@pnp/sp/presets/all"; +import * as React from "react"; import * as strings from "ControlStrings"; +import styles from "./DynamicForm.module.scss"; + +// Controls import { DefaultButton, PrimaryButton, } from "@fluentui/react/lib/Button"; -import { IDropdownOption } from "@fluentui/react/lib/components/Dropdown"; +import { + Dialog, + DialogFooter, + DialogType, +} from "@fluentui/react/lib/Dialog"; +import { IDropdownOption } from "@fluentui/react/lib/Dropdown"; +import { MessageBar, MessageBarType } from "@fluentui/react/lib/MessageBar"; import { ProgressIndicator } from "@fluentui/react/lib/ProgressIndicator"; import { IStackTokens, Stack } from "@fluentui/react/lib/Stack"; import { Icon } from "@fluentui/react/lib/components/Icon/Icon"; -import * as React from "react"; -import { IUploadImageResult } from "../../common/SPEntities"; -import SPservice from "../../services/SPService"; -import { FilePicker, IFilePickerResult } from "../filePicker"; import { DynamicField } from "./dynamicField"; import { DateFormat, FieldChangeAdditionalData, IDynamicFieldProps, } from "./dynamicField/IDynamicFieldProps"; -import styles from "./DynamicForm.module.scss"; -import { IDynamicFormProps } from "./IDynamicFormProps"; -import { IDynamicFormState } from "./IDynamicFormState"; -import { - Dialog, - DialogFooter, - DialogType, -} from "@fluentui/react/lib/Dialog"; +import { FilePicker, IFilePickerResult } from "../filePicker"; +// pnp/sp, helpers / utils +import { sp } from "@pnp/sp"; import "@pnp/sp/lists"; import "@pnp/sp/content-types"; import "@pnp/sp/folders"; import "@pnp/sp/items"; +import { IInstalledLanguageInfo } from "@pnp/sp/presets/all"; +import { cloneDeep, isEqual } from "lodash"; +import { ICustomFormatting, ICustomFormattingBodySection, ICustomFormattingNode } from "../../common/utilities/ICustomFormatting"; +import SPservice from "../../services/SPService"; +import { IRenderListDataAsStreamClientFormResult } from "../../services/ISPService"; +import { ISPField, IUploadImageResult } from "../../common/SPEntities"; +import { FormulaEvaluation } from "../../common/utilities/FormulaEvaluation"; +import { Context } from "../../common/utilities/FormulaEvaluation.types"; +import CustomFormattingHelper from "../../common/utilities/CustomFormatting"; + +// Dynamic Form Props / State +import { IDynamicFormProps } from "./IDynamicFormProps"; +import { IDynamicFormState } from "./IDynamicFormState"; const stackTokens: IStackTokens = { childrenGap: 20 }; @@ -44,14 +56,17 @@ export class DynamicForm extends React.Component< IDynamicFormState > { private _spService: SPservice; + private _formulaEvaluation: FormulaEvaluation; + private _customFormatter: CustomFormattingHelper; + private webURL = this.props.webAbsoluteUrl ? this.props.webAbsoluteUrl : this.props.context.pageContext.web.absoluteUrl; constructor(props: IDynamicFormProps) { super(props); - // Initialize pnp sp + // Initialize pnp sp if (this.props.webAbsoluteUrl) { sp.setup({ sp: { @@ -69,38 +84,108 @@ export class DynamicForm extends React.Component< // Initialize state this.state = { + infoErrorMessages: [], fieldCollection: [], + validationFormulas: {}, + clientValidationFormulas: {}, + validationErrors: {}, + hiddenByFormula: [], isValidationErrorDialogOpen: false, }; + // Get SPService Factory this._spService = this.props.webAbsoluteUrl ? new SPservice(this.props.context, this.props.webAbsoluteUrl) : new SPservice(this.props.context); + + // Setup Formula Validation utils + this._formulaEvaluation = new FormulaEvaluation(this.props.context, this.props.webAbsoluteUrl); + + // Setup Custom Formatting utils + this._customFormatter = new CustomFormattingHelper(this._formulaEvaluation); } /** * Lifecycle hook when component is mounted */ public componentDidMount(): void { - this.getFieldInformations() + this.getListInformation() .then(() => { /* no-op; */ }) - .catch(() => { + .catch((err) => { /* no-op; */ + console.error(err); }); } + public componentDidUpdate(prevProps: IDynamicFormProps, prevState: IDynamicFormState): void { + if (!isEqual(prevProps, this.props)) { + // Props have changed due to parent component or workbench config, reset state + this.setState({ + infoErrorMessages: [], // Reset info/error messages + validationErrors: {} // Reset validation errors + }, () => { + // If listId or listItemId have changed, reload list information + if (prevProps.listId !== this.props.listId || prevProps.listItemId !== this.props.listItemId) { + this.getListInformation() + .then(() => { + /* no-op; */ + }) + .catch((err) => { + /* no-op; */ + console.error(err); + }); + } else { + this.performValidation(); + } + }); + } + } + /** * Default React component render method */ public render(): JSX.Element { - const { fieldCollection, isSaving } = this.state; + const { customFormatting, fieldCollection, hiddenByFormula, infoErrorMessages, isSaving } = this.state; + + const customFormattingDisabled = this.props.useCustomFormatting === false; + + // Custom Formatting - Header + let headerContent: JSX.Element; + if (!customFormattingDisabled && customFormatting?.header) { + headerContent = this._customFormatter.renderCustomFormatContent(customFormatting.header, this.getFormValuesForValidation(), true) as JSX.Element; + } - const fieldOverrides = this.props.fieldOverrides; + // Custom Formatting - Body + const bodySections: ICustomFormattingBodySection[] = []; + if (!customFormattingDisabled && customFormatting?.body) { + bodySections.push(...customFormatting.body.slice()); + if (bodySections.length > 0) { + const specifiedFields: string[] = bodySections.reduce((prev, cur) => { + prev.push(...cur.fields); + return prev; + }, []); + const omittedFields = fieldCollection.filter(f => !specifiedFields.includes(f.label)).map(f => f.label); + bodySections[bodySections.length - 1].fields.push(...omittedFields); + } + } + + // Custom Formatting - Footer + let footerContent: JSX.Element; + if (!customFormattingDisabled && customFormatting?.footer) { + footerContent = this._customFormatter.renderCustomFormatContent(customFormatting.footer, this.getFormValuesForValidation(), true) as JSX.Element; + } + + // Content Type + let contentTypeId = this.props.contentTypeId; + if (this.state.contentTypeId !== undefined) contentTypeId = this.state.contentTypeId; return (
+ {infoErrorMessages.map((ie, i) => ( + {ie.message} + ))} {fieldCollection.length === 0 ? (
) : (
+ {headerContent} {this.props.enableFileSelection === true && this.props.listItemId === undefined && - this.props.contentTypeId !== undefined && - this.props.contentTypeId.startsWith("0x0101") && + contentTypeId !== undefined && + contentTypeId.startsWith("0x0101") && this.renderFileSelectionControl()} - {fieldCollection.map((v, i) => { - if ( - fieldOverrides && - Object.prototype.hasOwnProperty.call( - fieldOverrides, - v.columnInternalName - ) - ) { - v.disabled = v.disabled || isSaving; - return fieldOverrides[v.columnInternalName](v); - } - return ( - - ); - })} + {(bodySections.length > 0 && !customFormattingDisabled) && bodySections + .filter(bs => bs.fields.filter(bsf => hiddenByFormula.indexOf(bsf) < 0).length > 0) + .map((section, i) => ( + <> +

{section.displayname}

+
+ {section.fields.map((f, i) => ( +
+ {this.renderField(fieldCollection.find(fc => fc.label === f) as IDynamicFieldProps)} +
+ ))} +
+ {i < bodySections.length - 1 &&
} + + ))} + {(bodySections.length === 0 || customFormattingDisabled) && fieldCollection.map((f, i) => this.renderField(f))} + {footerContent} {!this.props.disabled && ( { + const { fieldOverrides } = this.props; + const { hiddenByFormula, isSaving, validationErrors } = this.state; + + // If the field is hidden by a formula, don't render it + if (hiddenByFormula.find(h => h === field.columnInternalName)) { + return null; + } + + // If validation error, show error message + let validationErrorMessage: string = ""; + if (validationErrors[field.columnInternalName]) { + validationErrorMessage = validationErrors[field.columnInternalName]; + } + + // If field override is provided, use it instead of the DynamicField component + if ( + fieldOverrides && + Object.prototype.hasOwnProperty.call( + fieldOverrides, + field.columnInternalName + ) + ) { + field.disabled = field.disabled || isSaving; + return fieldOverrides[field.columnInternalName](field); + } + + // Default render + return ( + + ); + } + + private updateFormMessages(type: MessageBarType, message: string): void { + const { infoErrorMessages } = this.state; + const newMessages = infoErrorMessages.slice(); + newMessages.push({ type, message }); + this.setState({ infoErrorMessages: newMessages }); + } + + /** Triggered when the user submits the form. */ private onSubmitClick = async (): Promise => { const { listId, listItemId, - contentTypeId, onSubmitted, onBeforeSubmit, onSubmitError, @@ -192,42 +320,62 @@ export class DynamicForm extends React.Component< validationErrorDialogProps, returnListItemInstanceOnSubmit } = this.props; + + let contentTypeId = this.props.contentTypeId; + if (this.state.contentTypeId !== undefined) contentTypeId = this.state.contentTypeId; + + const fileSelectRendered = !listItemId && contentTypeId.startsWith("0x0101") && enableFileSelection === true; try { + + /** Set to true to cancel form submission */ let shouldBeReturnBack = false; + const fields = (this.state.fieldCollection || []).slice(); - fields.forEach((val) => { - if (val.required) { - if (val.newValue === null) { + fields.forEach((field) => { + + // When a field is required and has no value + if (field.required) { + if (field.newValue === null) { if ( - val.fieldDefaultValue === null || - val.fieldDefaultValue === "" || - val.fieldDefaultValue.length === 0 || - val.fieldDefaultValue === undefined + field.defaultValue === null || + field.defaultValue === "" || + field.defaultValue.length === 0 || + field.defaultValue === undefined ) { - if (val.fieldType === "DateTime") val.fieldDefaultValue = null; - else val.fieldDefaultValue = ""; + if (field.fieldType === "DateTime") field.defaultValue = null; + else field.defaultValue = ""; shouldBeReturnBack = true; } - } else if (val.newValue === "") { - val.fieldDefaultValue = ""; + } else if (field.newValue === "") { + field.defaultValue = ""; shouldBeReturnBack = true; - } else if (Array.isArray(val.newValue) && val.newValue.length === 0) { - val.fieldDefaultValue = null; + } else if (Array.isArray(field.newValue) && field.newValue.length === 0) { + field.defaultValue = null; shouldBeReturnBack = true; } } - if (val.fieldType === "Number") { - if (val.showAsPercentage) val.newValue /= 100; - if (this.isEmptyNumOrString(val.newValue) && (val.minimumValue !== null || val.maximumValue !== null)) { - val.newValue = val.fieldDefaultValue = null; - } - if (!this.isEmptyNumOrString(val.newValue) && (isNaN(Number(val.newValue)) || (val.newValue < val.minimumValue) || (val.newValue > val.maximumValue))) { + + // Check min and max values for number fields + if (field.fieldType === "Number" && field.newValue !== undefined && field.newValue.trim() !== "") { + if ((field.newValue < field.minimumValue) || (field.newValue > field.maximumValue)) { shouldBeReturnBack = true; } } + }); + // Perform validation + const validationDisabled = this.props.useFieldValidation === false; + let validationErrors: Record = {}; + if (!validationDisabled) { + validationErrors = await this.evaluateFormulas(this.state.validationFormulas, true, true, this.state.hiddenByFormula) as Record; + if (Object.keys(validationErrors).length > 0) { + shouldBeReturnBack = true; + } + } + + // If validation failed, return without saving if (shouldBeReturnBack) { this.setState({ fieldCollection: fields, @@ -238,12 +386,13 @@ export class DynamicForm extends React.Component< return; } - if (enableFileSelection === true && this.state.selectedFile === undefined && this.props.listItemId === undefined) { + if (fileSelectRendered === true && this.state.selectedFile === undefined && this.props.listItemId === undefined) { this.setState({ missingSelectedFile: true, isValidationErrorDialogOpen: validationErrorDialogProps ?.showDialogOnValidationError === true, + validationErrors }); return; } @@ -252,55 +401,85 @@ export class DynamicForm extends React.Component< isSaving: true, }); + /** Item values for save / update */ const objects = {}; + for (let i = 0, len = fields.length; i < len; i++) { - const val = fields[i]; + const field = fields[i]; const { fieldType, additionalData, columnInternalName, hiddenFieldName, - } = val; - if (val.newValue !== null && val.newValue !== undefined) { - let value = val.newValue; + } = field; + if (field.newValue !== null && field.newValue !== undefined) { + + let value = field.newValue; + if (["Lookup", "LookupMulti", "User", "UserMulti"].indexOf(fieldType) < 0) { + objects[columnInternalName] = value; + } + + // Choice fields + + if (fieldType === "Choice") { + objects[columnInternalName] = field.newValue.key; + } + if (fieldType === "MultiChoice") { + objects[columnInternalName] = { results: field.newValue }; + } + + // Lookup fields + if (fieldType === "Lookup") { if (value && value.length > 0) { objects[`${columnInternalName}Id`] = value[0].key; } else { objects[`${columnInternalName}Id`] = null; } - } else if (fieldType === "LookupMulti") { + } + if (fieldType === "LookupMulti") { value = []; - val.newValue.forEach((element) => { + field.newValue.forEach((element) => { value.push(element.key); }); objects[`${columnInternalName}Id`] = { results: value.length === 0 ? null : value, }; - } else if (fieldType === "TaxonomyFieldType") { + } + + // User fields + + if (fieldType === "User") { + objects[`${columnInternalName}Id`] = field.newValue.length === 0 ? null : field.newValue; + } + if (fieldType === "UserMulti") { + objects[`${columnInternalName}Id`] = { + results: field.newValue.length === 0 ? null : field.newValue, + }; + } + + // Taxonomy / Managed Metadata fields + + if (fieldType === "TaxonomyFieldType") { objects[columnInternalName] = { __metadata: { type: "SP.Taxonomy.TaxonomyFieldValue" }, Label: value[0]?.name ?? "", TermGuid: value[0]?.key ?? "11111111-1111-1111-1111-111111111111", WssId: "-1", }; - } else if (fieldType === "TaxonomyFieldTypeMulti") { - objects[hiddenFieldName] = val.newValue + } + if (fieldType === "TaxonomyFieldTypeMulti") { + objects[hiddenFieldName] = field.newValue .map((term) => `-1#;${term.name}|${term.key};`) .join("#"); - } else if (fieldType === "User") { - objects[`${columnInternalName}Id`] = val.newValue.length === 0 ? null : val.newValue; - } else if (fieldType === "Choice") { - objects[columnInternalName] = val.newValue.key; - } else if (fieldType === "MultiChoice") { - objects[columnInternalName] = { results: val.newValue }; - } else if (fieldType === "Location") { - objects[columnInternalName] = JSON.stringify(val.newValue); - } else if (fieldType === "UserMulti") { - objects[`${columnInternalName}Id`] = { - results: val.newValue.length === 0 ? null : val.newValue, - }; - } else if (fieldType === "Thumbnail") { + } + + // Other fields + + if (fieldType === "Location") { + objects[columnInternalName] = JSON.stringify(field.newValue); + } + if (fieldType === "Thumbnail") { if (additionalData) { const uploadedImage = await this.uploadImage(additionalData); objects[columnInternalName] = JSON.stringify({ @@ -312,10 +491,7 @@ export class DynamicForm extends React.Component< } else { objects[columnInternalName] = null; } - } - else { - objects[columnInternalName] = val.newValue; - } + } } } @@ -330,6 +506,8 @@ export class DynamicForm extends React.Component< } } + let apiError: string; + // If we have the item ID, we simply need to update it let newETag: string | undefined = undefined; if (listItemId) { @@ -348,12 +526,14 @@ export class DynamicForm extends React.Component< ); } } catch (error) { + apiError = error.message; if (onSubmitError) { onSubmitError(objects, error); } console.log("Error", error); } } + // Otherwise, depending on the content type ID of the item, if any, we need to behave accordingly else if ( contentTypeId === undefined || @@ -361,14 +541,14 @@ export class DynamicForm extends React.Component< (!contentTypeId.startsWith("0x0120") && contentTypeId.startsWith("0x01")) ) { - if (contentTypeId === undefined || enableFileSelection === true) { + if (fileSelectRendered === true) { await this.addFileToLibrary(objects); } else { // We are adding a new list item try { const contentTypeIdField = "ContentTypeId"; - //check if item contenttype is passed, then update the object with content type id, else, pass the object + // check if item contenttype is passed, then update the object with content type id, else, pass the object if (contentTypeId !== undefined && contentTypeId.startsWith("0x01")) objects[contentTypeIdField] = contentTypeId; const iar = await sp.web.lists.getById(listId).items.add(objects); if (onSubmitted) { @@ -380,6 +560,7 @@ export class DynamicForm extends React.Component< ); } } catch (error) { + apiError = error.message; if (onSubmitError) { onSubmitError(objects, error); } @@ -428,19 +609,18 @@ export class DynamicForm extends React.Component< ); } } catch (error) { + apiError = error.message; if (onSubmitError) { onSubmitError(objects, error); } console.log("Error", error); } - } else if (contentTypeId.startsWith("0x01") && enableFileSelection === true) { - // We are adding a folder or a Document Set - await this.addFileToLibrary(objects); - } + } this.setState({ isSaving: false, etag: newETag, + infoErrorMessages: apiError ? [{ type: MessageBarType.error, message: apiError }] : [], }); } catch (error) { if (onSubmitError) { @@ -450,6 +630,9 @@ export class DynamicForm extends React.Component< } }; + /** + * Adds selected file to the library + */ private addFileToLibrary = async (objects: {}): Promise => { const { selectedFile @@ -463,66 +646,93 @@ export class DynamicForm extends React.Component< returnListItemInstanceOnSubmit } = this.props; - try { - const idField = "ID"; - const contentTypeIdField = "ContentTypeId"; - - const library = await sp.web.lists.getById(listId); - const itemTitle = - selectedFile !== undefined && selectedFile.fileName !== undefined && selectedFile.fileName !== "" - ? (selectedFile.fileName as string).replace( - /["|*|:|<|>|?|/|\\||]/g, - "_" - ) // Replace not allowed chars in folder name - : ""; // Empty string will be replaced by SPO with Folder Item ID - - const fileCreatedResult = await library.rootFolder.files.addChunked(encodeURI(itemTitle), await selectedFile.downloadFileContent()); - const fields = await fileCreatedResult.file.listItemAllFields(); - - if (fields[idField]) { - // Read the ID of the just created folder or Document Set - const folderId = fields[idField]; - - // Set the content type ID for the target item - objects[contentTypeIdField] = contentTypeId; - // Update the just created folder or Document Set - const iur = await library.items.getById(folderId).update(objects); - if (onSubmitted) { - onSubmitted( - iur.data, - returnListItemInstanceOnSubmit !== false - ? iur.item - : undefined - ); + + if (selectedFile !== undefined) { + try { + const idField = "ID"; + const contentTypeIdField = "ContentTypeId"; + + const library = await sp.web.lists.getById(listId); + const itemTitle = + selectedFile !== undefined && selectedFile.fileName !== undefined && selectedFile.fileName !== "" + ? (selectedFile.fileName as string).replace( + /["|*|:|<|>|?|/|\\||]/g, + "_" + ) // Replace not allowed chars in folder name + : ""; // Empty string will be replaced by SPO with Folder Item ID + + const fileCreatedResult = await library.rootFolder.files.addChunked(encodeURI(itemTitle), await selectedFile.downloadFileContent()); + const fields = await fileCreatedResult.file.listItemAllFields(); + + if (fields[idField]) { + // Read the ID of the just created folder or Document Set + const folderId = fields[idField]; + + // Set the content type ID for the target item + objects[contentTypeIdField] = contentTypeId; + // Update the just created folder or Document Set + const iur = await library.items.getById(folderId).update(objects); + if (onSubmitted) { + onSubmitted( + iur.data, + returnListItemInstanceOnSubmit !== false + ? iur.item + : undefined + ); + } + } else { + throw new Error( + "Unable to read the ID of the just created folder or Document Set" + ); + } + } catch (error) { + if (onSubmitError) { + onSubmitError(objects, error); + } + console.log("Error", error); } - } else { - throw new Error( - "Unable to read the ID of the just created folder or Document Set" - ); } - } catch (error) { - if (onSubmitError) { - onSubmitError(objects, error); - } - console.log("Error", error); - } } - // trigger when the user change any value in the form + /** + * Triggered when the user makes any field value change in the form + */ private onChange = async ( internalName: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any newValue: any, - additionalData?: FieldChangeAdditionalData + validate: boolean, + additionalData?: FieldChangeAdditionalData, ): Promise => { - // eslint-disable-line @typescript-eslint/no-explicit-any - // try { - const fieldCol = (this.state.fieldCollection || []).slice(); + + const fieldCol = cloneDeep(this.state.fieldCollection || []); const field = fieldCol.filter((element, i) => { return element.columnInternalName === internalName; })[0]; + + // Init new value(s) field.newValue = newValue; + field.stringValue = newValue.toString(); field.additionalData = additionalData; + field.subPropertyValues = {}; + + // Store string values for various field types + + if (field.fieldType === "Choice") { + field.stringValue = newValue.text; + } + if (field.fieldType === "MultiChoice") { + field.stringValue = newValue.join(';#'); + } + if (field.fieldType === "Lookup" || field.fieldType === "LookupMulti") { + field.stringValue = newValue.map(nv => nv.key + ';#' + nv.name).join(';#'); + } + if (field.fieldType === "TaxonomyFieldType" || field.fieldType === "TaxonomyFieldTypeMulti") { + field.stringValue = newValue.map(nv => nv.name).join(';'); + } + + // Capture additional property data for User fields + if (field.fieldType === "User" && newValue.length !== 0) { if ( newValue[0].id === undefined || @@ -534,11 +744,19 @@ export class DynamicForm extends React.Component< } const result = await sp.web.ensureUser(user); field.newValue = result.data.Id; // eslint-disable-line require-atomic-updates + field.stringValue = user; + field.subPropertyValues = { + id: result.data.Id, + title: result.data.Title, + email: result.data.Email, + }; } else { field.newValue = newValue[0].id; } - } else if (field.fieldType === "UserMulti" && newValue.length !== 0) { + } + if (field.fieldType === "UserMulti" && newValue.length !== 0) { field.newValue = []; + const emails: string[] = []; for (let index = 0; index < newValue.length; index++) { const element = newValue[index]; if ( @@ -551,18 +769,141 @@ export class DynamicForm extends React.Component< } const result = await sp.web.ensureUser(user); field.newValue.push(result.data.Id); + emails.push(user); } else { field.newValue.push(element.id); } } + field.stringValue = emails.join(";"); } + + const validationErrors = {...this.state.validationErrors}; + if (validationErrors[field.columnInternalName]) delete validationErrors[field.columnInternalName]; + this.setState({ fieldCollection: fieldCol, + validationErrors + }, () => { + if (validate) this.performValidation(); }); }; - //getting all the fields information as part of get ready process - private getFieldInformations = async (): Promise => { + /** Validation callback, used when form first loads (getListInformation) and following onChange */ + private performValidation = (skipFieldValueValidation?: boolean): void => { + const { useClientSideValidation, useFieldValidation } = this.props; + const { clientValidationFormulas, validationFormulas } = this.state; + if (Object.keys(clientValidationFormulas).length || Object.keys(validationFormulas).length) { + this.setState({ + isSaving: true, // Disable save btn and fields while validation in progress + }, () => { + const clientSideValidationDisabled = useClientSideValidation === false; + const fieldValidationDisabled = useFieldValidation === false; + const hiddenByFormula: string[] = !clientSideValidationDisabled ? this.evaluateColumnVisibilityFormulas() : []; + let validationErrors = { ...this.state.validationErrors }; + if (!skipFieldValueValidation && !fieldValidationDisabled) validationErrors = this.evaluateFieldValueFormulas(hiddenByFormula); + this.setState({ hiddenByFormula, isSaving: false, validationErrors }); + }); + } + } + + /** Determines visibility of fields that have show/hide formulas set in Edit Form > Edit Columns > Edit Conditional Formula */ + private evaluateColumnVisibilityFormulas = (): string[] => { + return this.evaluateFormulas(this.state.clientValidationFormulas, false) as string[]; + } + + /** Evaluates field validation formulas set in column settings and returns a Record of error messages */ + private evaluateFieldValueFormulas = (hiddenFields: string[]): Record => { + return this.evaluateFormulas(this.state.validationFormulas, true, true, hiddenFields) as Record; + } + + /** + * Evaluates formulas and returns a Record of error messages or an array of column names that have failed validation + * @param formulas A Record / dictionary-like object, where key is internal column name and value is an object with ValidationFormula and ValidationMessage properties + * @param returnMessages Determines whether a Record of error messages is returned or an array of column names that have failed validation + * @param requireValue Set to true if the formula should only be evaluated when the field has a value + * @returns + */ + private evaluateFormulas = ( + formulas: Record>, + returnMessages = true, + requireValue: boolean = false, + ignoreFields: string[] = [] + ): string[] | Record => { + const { fieldCollection } = this.state; + const results: Record = {}; + for (let i = 0; i < Object.keys(formulas).length; i++) { + const fieldName = Object.keys(formulas)[i]; + if (formulas[fieldName]) { + const field = fieldCollection.find(f => f.columnInternalName === fieldName); + if (!field) continue; + if (ignoreFields.indexOf(fieldName) > -1) continue; // Skip fields that are being ignored (e.g. hidden by formula) + const formula = formulas[fieldName].ValidationFormula; + const message = formulas[fieldName].ValidationMessage; + if (!formula) continue; + const context = this.getFormValuesForValidation(); + if (requireValue && !context[fieldName]) continue; + const result = this._formulaEvaluation.evaluate(formula, context); + if (Boolean(result) !== true) { + results[fieldName] = message; + } + } + } + if (!returnMessages) { return Object.keys(results); } + return results; + } + + /** + * Used for validation. Returns a Record of field values, where key is internal column name and value is the field value. + * Expands certain properties and stores many of them as primitives (strings, numbers or bools) so the expression evaluator + * can process them. For example: a User column named Person will have values stored as Person, Person.email, Person.title etc. + * This is so the expression evaluator can process expressions like '=[$Person.title] == "Contoso Employee 1138"' + * @param fieldCollection Optional. Could be used to compare field values in state with previous state. + * @returns + */ + private getFormValuesForValidation = (fieldCollection?: IDynamicFieldProps[]): Context => { + const { fieldCollection: fieldColFromState } = this.state; + if (!fieldCollection) fieldCollection = fieldColFromState; + return fieldCollection.reduce((prev, cur) => { + let value: boolean | number | object | string | undefined = cur.value; + switch (cur.fieldType) { + case "Lookup": + case "Choice": + case "TaxonomyFieldType": + case "LookupMulti": + case "MultiChoice": + case "TaxonomyFieldTypeMulti": + case "User": + case "UserMulti": + value = cur.stringValue; + break; + case "Currency": + case "Number": + if (cur.value !== undefined && cur.value !== null) value = Number(cur.value); + if (cur.newValue !== undefined && cur.newValue !== null) value = Number(cur.newValue); + break; + case "URL": + if (cur.value !== undefined && cur.value !== null) value = cur.value.Url; + if (cur.newValue !== undefined && cur.newValue !== null) value = cur.newValue.Url; + value = cur.newValue ? cur.newValue.Url : null; + break; + default: + value = cur.newValue || cur.value; + break; + } + prev[cur.columnInternalName] = value; + if (cur.subPropertyValues) { + Object.keys(cur.subPropertyValues).forEach((key) => { + prev[`${cur.columnInternalName}.${key}`] = cur.subPropertyValues[key]; + }); + } + return prev; + }, {} as Context); + } + + /** + * Invoked when component first mounts, loads information about the SharePoint list, fields and list item + */ + private getListInformation = async (): Promise => { const { listId, listItemId, @@ -571,12 +912,63 @@ export class DynamicForm extends React.Component< onListItemLoaded, } = this.props; let contentTypeId = this.props.contentTypeId; + try { - const spList = await sp.web.lists.getById(listId); + + // Fetch form rendering information from SharePoint + const listInfo = await this._spService.getListFormRenderInfo(listId); + + // Fetch additional information about fields from SharePoint + // (Number fields for min and max values, and fields with validation) + const additionalInfo = await this._spService.getAdditionalListFormFieldInfo(listId); + const numberFields = additionalInfo.filter((f) => f.TypeAsString === "Number" || f.TypeAsString === "Currency"); + + // Build a dictionary of validation formulas and messages + const validationFormulas: Record> = additionalInfo.reduce((prev, cur) => { + if (!prev[cur.InternalName] && cur.ValidationFormula) { + prev[cur.InternalName] = { + ValidationFormula: cur.ValidationFormula, + ValidationMessage: cur.ValidationMessage, + }; + } + return prev; + }, {}); + + // If no content type ID is provided, use the default (first one in the list) + if (contentTypeId === undefined || contentTypeId === "") { + contentTypeId = Object.keys(listInfo.ContentTypeIdToNameMap)[0]; + } + const contentTypeName: string = listInfo.ContentTypeIdToNameMap[contentTypeId]; + + // Build a dictionary of client validation formulas and messages + // These are formulas that are added in Edit Form > Edit Columns > Edit Conditional Formula + // They are evaluated on the client side, and determine whether a field should be hidden or shown + const clientValidationFormulas = listInfo.ClientForms.Edit[contentTypeName].reduce((prev, cur) => { + if (cur.ClientValidationFormula) { + prev[cur.InternalName] = { + ValidationFormula: cur.ClientValidationFormula, + ValidationMessage: cur.ClientValidationMessage, + }; + } + return prev; + }, {} as Record>); + + // Custom Formatting + let headerJSON: ICustomFormattingNode, footerJSON: ICustomFormattingNode; + let bodySections: ICustomFormattingBodySection[]; + if (listInfo.ClientFormCustomFormatter && listInfo.ClientFormCustomFormatter[contentTypeId]) { + const customFormatInfo = JSON.parse(listInfo.ClientFormCustomFormatter[contentTypeId]) as ICustomFormatting; + bodySections = customFormatInfo.bodyJSONFormatter.sections; + headerJSON = customFormatInfo.headerJSONFormatter; + footerJSON = customFormatInfo.footerJSONFormatter; + } + + // Load SharePoint list item + const spList = sp.web.lists.getById(listId); let item = null; let etag: string | undefined = undefined; if (listItemId !== undefined && listItemId !== null && listItemId !== 0) { - item = await spList.items.getById(listItemId).get(); + item = await spList.items.getById(listItemId).get().catch(err => this.updateFormMessages(MessageBarType.error, err.message)); if (onListItemLoaded) { await onListItemLoaded(item); @@ -587,263 +979,321 @@ export class DynamicForm extends React.Component< } } - if (contentTypeId === undefined || contentTypeId === "") { - const defaultContentType = await spList.contentTypes - .select("Id", "Name") - .get(); - contentTypeId = defaultContentType[0].Id.StringValue; - } - const listFields = await this.getFormFields( + // Build the field collection + const tempFields: IDynamicFieldProps[] = await this.buildFieldCollection( + listInfo, + contentTypeName, + item, + numberFields, listId, - contentTypeId, - this.webURL + listItemId, + disabledFields ); - const tempFields: IDynamicFieldProps[] = []; - let order: number = 0; - const responseValue = listFields.value; - const hiddenFields = - this.props.hiddenFields !== undefined ? this.props.hiddenFields : []; - let defaultDayOfWeek: number = 0; - for (let i = 0, len = responseValue.length; i < len; i++) { - const field = responseValue[i]; - - // Handle only fields that are not marked as hidden - if (hiddenFields.indexOf(field.EntityPropertyName) < 0) { - order++; - const fieldType = field.TypeAsString; - field.order = order; - let cultureName: string; - let hiddenName = ""; - let termSetId = ""; - let anchorId = ""; - let lookupListId = ""; - let lookupField = ""; - const choices: IDropdownOption[] = []; - let defaultValue = null; - const selectedTags: any = []; // eslint-disable-line @typescript-eslint/no-explicit-any - let richText = false; - let dateFormat: DateFormat | undefined; - let principalType = ""; - let minValue: number | undefined; - let maxValue: number | undefined; - let showAsPercentage: boolean | undefined; + + // Get installed languages for Currency fields + let installedLanguages: IInstalledLanguageInfo[]; + if (tempFields.filter(f => f.fieldType === "Currency").length > 0) { + installedLanguages = await sp.web.regionalSettings.getInstalledLanguages(); + } + + this.setState({ + contentTypeId, + clientValidationFormulas, + customFormatting: { + header: headerJSON, + body: bodySections, + footer: footerJSON + }, + etag, + fieldCollection: tempFields, + installedLanguages, + validationFormulas + }, () => this.performValidation(true)); + + } catch (error) { + this.updateFormMessages(MessageBarType.error, 'An error occurred while loading: ' + error.message); + console.error(`An error occurred while loading DynamicForm`, error); + return null; + } + } + + /** + * Builds a collection of fields to be rendered in the form + * @param listInfo Data returned by RenderListDataAsStream with RenderOptions = 64 (ClientFormSchema) + * @param contentTypeName SharePoint List Content Type + * @param item SharePoint List Item + * @param numberFields Additional information about Number fields (min and max values) + * @param listId SharePoint List ID + * @param listItemId SharePoint List Item ID + * @param disabledFields Fields that should be disabled due to configuration + * @returns + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async buildFieldCollection(listInfo: IRenderListDataAsStreamClientFormResult, contentTypeName: string, item: any, numberFields: ISPField[], listId: string, listItemId: number, disabledFields: string[]): Promise { + const tempFields: IDynamicFieldProps[] = []; + let order: number = 0; + const hiddenFields = this.props.hiddenFields !== undefined ? this.props.hiddenFields : []; + let defaultDayOfWeek: number = 0; + + for (let i = 0, len = listInfo.ClientForms.Edit[contentTypeName].length; i < len; i++) { + const field = listInfo.ClientForms.Edit[contentTypeName][i]; + + // Process fields that are not marked as hidden + if (hiddenFields.indexOf(field.InternalName) < 0) { + order++; + let hiddenName = ""; + let termSetId = ""; + let anchorId = ""; + let lookupListId = ""; + let lookupField = ""; + const choices: IDropdownOption[] = []; + let defaultValue = null; + let value = undefined; + let stringValue = null; + const subPropertyValues: Record = {}; + let richText = false; + let dateFormat: DateFormat | undefined; + let principalType = ""; + let cultureName: string; + let minValue: number | undefined; + let maxValue: number | undefined; + let showAsPercentage: boolean | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const selectedTags: any = []; + + // If a SharePoint Item was loaded, get the field value from it + if (item !== null && item[field.InternalName]) { + value = item[field.InternalName]; + stringValue = value.toString(); + } else { + defaultValue = field.DefaultValue; + } + + // Store choices for Choice fields + if (field.FieldType === "Choice") { + field.Choices.forEach((element) => { + choices.push({ key: element, text: element }); + }); + } + if (field.FieldType === "MultiChoice") { + field.MultiChoices.forEach((element) => { + choices.push({ key: element, text: element }); + }); + } + + // Setup Note, Number and Currency fields + if (field.FieldType === "Note") { + richText = field.RichText; + } + if (field.FieldType === "Number" || field.FieldType === "Currency") { + const numberField = numberFields.find(f => f.InternalName === field.InternalName); + if (numberField) { + minValue = numberField.MinimumValue; + maxValue = numberField.MaximumValue; + } + showAsPercentage = field.ShowAsPercentage; + if (field.FieldType === "Currency") { + cultureName = this.cultureNameLookup(numberField.CurrencyLocaleId); + } + } + + // Setup Lookup fields + if (field.FieldType === "Lookup" || field.FieldType === "LookupMulti") { + lookupListId = field.LookupListId; + lookupField = field.LookupFieldName; if (item !== null) { - defaultValue = item[field.EntityPropertyName]; + value = await this._spService.getLookupValues( + listId, + listItemId, + field.InternalName, + lookupField, + this.webURL + ); + stringValue = value?.map(dv => dv.key + ';#' + dv.name).join(';#'); + if (item[field.InternalName + "Id"]) { + subPropertyValues.id = item[field.InternalName + "Id"]; + subPropertyValues.lookupId = subPropertyValues.id; + } + subPropertyValues.lookupValue = value?.map(dv => dv.name); } else { - defaultValue = field.DefaultValue; + value = []; } - if (fieldType === "Choice" || fieldType === "MultiChoice") { - field.Choices.forEach((element) => { - choices.push({ key: element, text: element }); - }); - } else if (fieldType === "Note") { - richText = field.RichText; - } else if (fieldType === "Number" || fieldType === "Currency") { - minValue = field.MinimumValue; - maxValue = field.MaximumValue; - if (fieldType === "Number") { - showAsPercentage = field.ShowAsPercentage; - } else { - cultureName = this.cultureNameLookup(field.CurrencyLocaleId); - } - } else if (fieldType === "Lookup") { - lookupListId = field.LookupList; - lookupField = field.LookupField; - if (item !== null) { - defaultValue = await this._spService.getLookupValue( - listId, - listItemId, - field.EntityPropertyName, - lookupField, - this.webURL - ); - } else { - defaultValue = []; - } - } else if (fieldType === "LookupMulti") { - lookupListId = field.LookupList; - lookupField = field.LookupField; - if (item !== null) { - defaultValue = await this._spService.getLookupValues( + } + + // Setup User fields + if (field.FieldType === "User") { + if (item !== null) { + const userEmails: string[] = []; + userEmails.push( + (await this._spService.getUserUPNFromFieldValue( listId, listItemId, - field.EntityPropertyName, - lookupField, + field.InternalName, this.webURL - ); - } else { - defaultValue = []; + )) + "" + ); + value = userEmails; + stringValue = userEmails?.map(dv => dv.split('/').shift()).join(';'); + if (item[field.InternalName + "Id"]) { + subPropertyValues.id = item[field.InternalName + "Id"]; } - } else if (fieldType === "TaxonomyFieldTypeMulti") { - const response = await this._spService.getTaxonomyFieldInternalName( - this.props.listId, - field.TextField, + subPropertyValues.title = userEmails?.map(dv => dv.split('/').pop())[0]; + subPropertyValues.email = userEmails[0]; + } else { + value = []; + } + principalType = field.PrincipalAccountType; + } + if (field.FieldType === "UserMulti") { + if (item !== null) { + value = await this._spService.getUsersUPNFromFieldValue( + listId, + listItemId, + field.InternalName, this.webURL ); - hiddenName = response.value; - termSetId = field.TermSetId; - anchorId = field.AnchorId; - if (item !== null && item[field.InternalName] !== null && item[field.InternalName].results !== null) { - item[field.InternalName].results.forEach((element) => { - selectedTags.push({ - key: element.TermGuid, - name: element.Label, - }); - }); + stringValue = value?.map(dv => dv.split('/').pop()).join(';'); + } else { + value = []; + } + principalType = field.PrincipalAccountType; + } - defaultValue = selectedTags; - } else { - if (defaultValue !== null && defaultValue !== "") { - defaultValue.split(/#|;/).forEach((element) => { - if (element.indexOf("|") !== -1) - selectedTags.push({ - key: element.split("|")[1], - name: element.split("|")[0], - }); - }); - - defaultValue = selectedTags; - } - } - if (defaultValue === "") defaultValue = null; - } else if (fieldType === "TaxonomyFieldType") { - termSetId = field.TermSetId; - anchorId = field.AnchorId; - if (item !== null) { - const response = - await this._spService.getSingleManagedMetadataLabel( - listId, - listItemId, - field.InternalName - ); - if (response) { - selectedTags.push({ - key: response.TermID, - name: response.Label, - }); - defaultValue = selectedTags; - } - } else { - if (defaultValue !== "") { - selectedTags.push({ - key: defaultValue.split("|")[1], - name: defaultValue.split("|")[0].split("#")[1], - }); - defaultValue = selectedTags; - } + // Setup Taxonomy / Metadata fields + if (field.FieldType === "TaxonomyFieldType") { + termSetId = field.TermSetId; + anchorId = field.AnchorId; + if (item !== null) { + const response = await this._spService.getSingleManagedMetadataLabel( + listId, + listItemId, + field.InternalName + ); + if (response) { + selectedTags.push({ + key: response.TermID, + name: response.Label, + }); + value = selectedTags; + stringValue = selectedTags?.map(dv => dv.key + ';#' + dv.name).join(';#'); } - if (defaultValue === "") defaultValue = null; - } else if (fieldType === "DateTime") { - if (item !== null && item[field.InternalName]) - defaultValue = new Date(item[field.InternalName]); - else if (defaultValue === "[today]") { - defaultValue = new Date(); + } else { + if (defaultValue !== "") { + selectedTags.push({ + key: defaultValue.split("|")[1], + name: defaultValue.split("|")[0].split("#")[1], + }); + value = selectedTags; } + } + if (defaultValue === "") defaultValue = null; + } + if (field.FieldType === "TaxonomyFieldTypeMulti") { + hiddenName = field.HiddenListInternalName; + termSetId = field.TermSetId; + anchorId = field.AnchorId; + if (item !== null) { + item[field.InternalName].forEach((element) => { + selectedTags.push({ + key: element.TermGuid, + name: element.Label, + }); + }); - const schemaXml = field.SchemaXml; - const dateFormatRegEx = /\s+Format="([^"]+)"/gim.exec(schemaXml); - dateFormat = - dateFormatRegEx && dateFormatRegEx.length - ? (dateFormatRegEx[1] as DateFormat) - : "DateOnly"; - defaultDayOfWeek = (await this._spService.getRegionalWebSettings()) - .FirstDayOfWeek; - } else if (fieldType === "UserMulti") { - if (item !== null) - defaultValue = await this._spService.getUsersUPNFromFieldValue( - listId, - listItemId, - field.InternalName, - this.webURL - ); - else { - defaultValue = []; - } - principalType = field.SchemaXml.split('UserSelectionMode="')[1]; - principalType = principalType.substring( - 0, - principalType.indexOf('"') - ); - } else if (fieldType === "Thumbnail") { - if (defaultValue) { - defaultValue = JSON.parse(defaultValue).serverRelativeUrl; - } - } else if (fieldType === "User") { - if (item !== null) { - const userEmails: string[] = []; - userEmails.push( - (await this._spService.getUserUPNFromFieldValue( - listId, - listItemId, - field.InternalName, - this.webURL - )) + "" - ); - defaultValue = userEmails; - } else { - defaultValue = []; + defaultValue = selectedTags; + } else { + if (defaultValue !== null && defaultValue !== "") { + defaultValue.split(/#|;/).forEach((element) => { + if (element.indexOf("|") !== -1) + selectedTags.push({ + key: element.split("|")[1], + name: element.split("|")[0], + }); + }); + + value = selectedTags; + stringValue = selectedTags?.map(dv => dv.key + ';#' + dv.name).join(';#'); } - principalType = field.SchemaXml.split('UserSelectionMode="')[1]; - principalType = principalType.substring( - 0, - principalType.indexOf('"') - ); - } else if (fieldType === "Location") { - defaultValue = JSON.parse(defaultValue); - } else if (fieldType === "Boolean") { - defaultValue = Boolean(Number(defaultValue)); } + if (defaultValue === "") defaultValue = null; + } - tempFields.push({ - newValue: null, - fieldTermSetId: termSetId, - fieldAnchorId: anchorId, - options: choices, - lookupListID: lookupListId, - lookupField: lookupField, - cultureName, - changedValue: defaultValue, - fieldType: field.TypeAsString, - fieldTitle: field.Title, - fieldDefaultValue: defaultValue, - context: this.props.context, - disabled: - this.props.disabled || - (disabledFields && - disabledFields.indexOf(field.InternalName) > -1), - listId: this.props.listId, - columnInternalName: field.EntityPropertyName, - label: field.Title, - onChanged: this.onChange, - required: field.Required, - hiddenFieldName: hiddenName, - Order: field.order, - isRichText: richText, - dateFormat: dateFormat, - firstDayOfWeek: defaultDayOfWeek, - listItemId: listItemId, - principalType: principalType, - description: field.Description, - minimumValue: minValue, - maximumValue: maxValue, - showAsPercentage: showAsPercentage - }); - tempFields.sort((a, b) => a.Order - b.Order); + // Setup DateTime fields + if (field.FieldType === "DateTime") { + if (item !== null && item[field.InternalName]) { + value = new Date(item[field.InternalName]); + stringValue = value.toISOString(); + } else if (defaultValue === "[today]") { + defaultValue = new Date(); + } else if (defaultValue) { + defaultValue = new Date(defaultValue); + } + + dateFormat = field.DateFormat || "DateOnly"; + defaultDayOfWeek = (await this._spService.getRegionalWebSettings()).FirstDayOfWeek; } - } - let installedLanguages: IInstalledLanguageInfo[]; - if (tempFields.filter(f => f.fieldType === "Currency").length > 0) { - installedLanguages = await sp.web.regionalSettings.getInstalledLanguages(); - } + // Setup Thumbnail, Location and Boolean fields + if (field.FieldType === "Thumbnail") { + if (defaultValue) { + defaultValue = JSON.parse(defaultValue).serverRelativeUrl; + } + if (value) { + value = JSON.parse(value).serverRelativeUrl; + } + } + if (field.FieldType === "Location") { + if (defaultValue) defaultValue = JSON.parse(defaultValue); + if (value) value = JSON.parse(value); + } + if (field.FieldType === "Boolean") { + if (defaultValue !== undefined && defaultValue !== null) defaultValue = Boolean(Number(defaultValue)); + if (value !== undefined && value !== null) value = Boolean(Number(value)); + } - this.setState({ fieldCollection: tempFields, installedLanguages, etag }); - //return arrayItems; - } catch (error) { - console.log(`Error get field informations`, error); - return null; + tempFields.push({ + value, + newValue: undefined, + stringValue, + subPropertyValues, + cultureName, + fieldTermSetId: termSetId, + fieldAnchorId: anchorId, + options: choices, + lookupListID: lookupListId, + lookupField: lookupField, + // changedValue: defaultValue, + fieldType: field.FieldType, + // fieldTitle: field.Title, + defaultValue: defaultValue, + context: this.props.context, + disabled: this.props.disabled || + (disabledFields && + disabledFields.indexOf(field.InternalName) > -1), + // listId: this.props.listId, + columnInternalName: field.InternalName, + label: field.Title, + onChanged: this.onChange, + required: field.Required, + hiddenFieldName: hiddenName, + Order: order, + isRichText: richText, + dateFormat: dateFormat, + firstDayOfWeek: defaultDayOfWeek, + listItemId: listItemId, + principalType: principalType, + description: field.Description, + minimumValue: minValue, + maximumValue: maxValue, + showAsPercentage: showAsPercentage, + }); + + // This may not be necessary now using RenderListDataAsStream + tempFields.sort((a, b) => a.Order - b.Order); + } } - }; + return tempFields; + } private cultureNameLookup(lcid: number): string { const pageCulture = this.props.context.pageContext.cultureInfo.currentCultureName; @@ -885,50 +1335,6 @@ export class DynamicForm extends React.Component< }); }; - private getFormFields = async ( - listId: string, - contentTypeId: string | undefined, - webUrl?: string - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ): Promise => { - // eslint-disable-line @typescript-eslint/no-explicit-any - try { - const { context } = this.props; - const webAbsoluteUrl = !webUrl ? this.webURL : webUrl; - let apiUrl = ""; - if (contentTypeId !== undefined && contentTypeId !== "") { - if (contentTypeId.startsWith("0x0120")) { - apiUrl = `${webAbsoluteUrl}/_api/web/lists(@listId)/contenttypes('${contentTypeId}')/fields?@listId=guid'${encodeURIComponent( - listId - )}'&$filter=ReadOnlyField eq false and (Hidden eq false or StaticName eq 'Title') and (FromBaseType eq false or StaticName eq 'Title')`; - } else { - apiUrl = `${webAbsoluteUrl}/_api/web/lists(@listId)/contenttypes('${contentTypeId}')/fields?@listId=guid'${encodeURIComponent( - listId - )}'&$filter=ReadOnlyField eq false and Hidden eq false and (FromBaseType eq false or StaticName eq 'Title')`; - } - } else { - apiUrl = `${webAbsoluteUrl}/_api/web/lists(@listId)/fields?@listId=guid'${encodeURIComponent( - listId - )}'&$filter=ReadOnlyField eq false and Hidden eq false and (FromBaseType eq false or StaticName eq 'Title')`; - } - const data = await context.spHttpClient.get( - apiUrl, - SPHttpClient.configurations.v1 - ); - if (data.ok) { - const results = await data.json(); - if (results) { - return results; - } - } - - return null; - } catch (error) { - console.dir(error); - return Promise.reject(error); - } - }; - private closeValidationErrorDialog = (): void => { this.setState({ isValidationErrorDialogOpen: false, @@ -1028,7 +1434,4 @@ export class DynamicForm extends React.Component< } } - private isEmptyNumOrString(value: string | number): boolean { - if ((value?.toString().trim().length || 0) === 0) return true; - } } diff --git a/src/controls/dynamicForm/IDynamicFormProps.ts b/src/controls/dynamicForm/IDynamicFormProps.ts index 0c67f558c..43da7003e 100644 --- a/src/controls/dynamicForm/IDynamicFormProps.ts +++ b/src/controls/dynamicForm/IDynamicFormProps.ts @@ -78,6 +78,21 @@ export interface IDynamicFormProps { */ respectETag?: boolean; + /** + * Specifies whether custom formatting (set when customizing the out of the box form) should be used. Default - true + */ + useCustomFormatting?: boolean; + + /** + * Specifies whether client side validation should be used. Default - true + */ + useClientSideValidation?: boolean; + + /** + * Specifies whether field validation (set in column settings) should be used. Default - true + */ + useFieldValidation?: boolean; + /** * Specify validation error dialog properties */ diff --git a/src/controls/dynamicForm/IDynamicFormState.ts b/src/controls/dynamicForm/IDynamicFormState.ts index 9a91f3a41..5818690dc 100644 --- a/src/controls/dynamicForm/IDynamicFormState.ts +++ b/src/controls/dynamicForm/IDynamicFormState.ts @@ -1,13 +1,37 @@ import { IInstalledLanguageInfo } from '@pnp/sp/regional-settings'; +import { ISPField } from '../../common/SPEntities'; +import { MessageBarType } from '@fluentui/react/lib/MessageBar'; +import { ICustomFormattingBodySection, ICustomFormattingNode } from '../../common/utilities/ICustomFormatting'; import { IDynamicFieldProps } from './dynamicField/IDynamicFieldProps'; import { IFilePickerResult } from "../filePicker"; export interface IDynamicFormState { + infoErrorMessages: { + type: MessageBarType; + message: string; + }[]; + /** Form and List Item data */ fieldCollection: IDynamicFieldProps[]; installedLanguages?: IInstalledLanguageInfo[]; + /** Validation Formulas set in List Column settings */ + validationFormulas: Record>; + /** Field Show / Hide Validation Formulas, set in Edit Form > Edit Columns > Edit Conditional Formula */ + clientValidationFormulas: Record>; + /** Tracks fields hidden by ClientValidationFormula */ + hiddenByFormula: string[]; + /** Populated by evaluation of List Column Setting validation. Key is internal field name, value is the configured error message. */ + validationErrors: Record; + customFormatting?: { + header: ICustomFormattingNode; + body: ICustomFormattingBodySection[]; + footer: ICustomFormattingNode; + } + headerContent?: JSX.Element; + footerContent?: JSX.Element; isSaving?: boolean; etag?: string; isValidationErrorDialogOpen: boolean; selectedFile?: IFilePickerResult; missingSelectedFile?: boolean; + contentTypeId?: string; } diff --git a/src/controls/dynamicForm/dynamicField/DynamicField.tsx b/src/controls/dynamicForm/dynamicField/DynamicField.tsx index b8641731a..dc4deb6f4 100644 --- a/src/controls/dynamicForm/dynamicField/DynamicField.tsx +++ b/src/controls/dynamicForm/dynamicField/DynamicField.tsx @@ -22,10 +22,8 @@ import { IPickerTerms, TaxonomyPicker } from '../../taxonomyPicker'; import styles from '../DynamicForm.module.scss'; import { IDynamicFieldProps } from './IDynamicFieldProps'; import { IDynamicFieldState } from './IDynamicFieldState'; -import { isArray } from 'lodash'; import CurrencyMap from "../CurrencyMap"; - export class DynamicField extends React.Component { constructor(props: IDynamicFieldProps) { @@ -34,12 +32,12 @@ export class DynamicField extends React.Component{labelText}; - const errorText = this.getRequiredErrorText(); + const labelEl = ; + const errorText = this.props.validationErrorMessage || this.getRequiredErrorText(); const errorTextEl = {errorText}; const descriptionEl = {description}; const hasImage = !!changedValue; + const valueToDisplay = newValue !== undefined ? newValue : value; + switch (fieldType) { case 'loading': return { this.onChange(newText); }} @@ -131,7 +131,7 @@ export class DynamicField extends React.Component
@@ -139,7 +139,7 @@ export class DynamicField extends React.Component { this.onChange(newText); return newText; }} isEditMode={!disabled} /> @@ -155,6 +155,7 @@ export class DynamicField extends React.Component { this.onChange(option); }} + defaultSelectedKey={valueToDisplay ? undefined : defaultValue} + selectedKey={typeof valueToDisplay === "object" ? valueToDisplay?.key : valueToDisplay} + onChange={(e, option) => { this.onChange(option, true); }} onBlur={this.onBlur} errorMessage={errorText} /> {descriptionEl} @@ -190,7 +192,8 @@ export class DynamicField extends React.Component { this.onChange(newValue); }} - defaultValue={defaultValue} + onChange={(newValue) => { this.onChange(newValue, true); }} + defaultValue={valueToDisplay !== undefined ? valueToDisplay : defaultValue} errorMessage={errorText} /> {descriptionEl} @@ -217,7 +220,7 @@ export class DynamicField extends React.Component
@@ -232,7 +235,7 @@ export class DynamicField extends React.Component { this.onChange(newValue); }} + onSelectedItem={(newValue) => { this.onChange(newValue, true); }} context={context} /> {descriptionEl} @@ -241,7 +244,7 @@ export class DynamicField extends React.Component
@@ -256,7 +259,7 @@ export class DynamicField extends React.Component { this.onChange(newValue); }} + onSelectedItem={(newValue) => { this.onChange(newValue, true); }} context={context} /> {descriptionEl} @@ -273,14 +276,15 @@ export class DynamicField extends React.Component { this.onChange(newText); }} disabled={disabled} onBlur={this.onBlur} - errorMessage={customNumberErrorMessage} - min={minimumValue} + errorMessage={errorText || customNumberErrorMessage} + min={minimumValue} max={maximumValue} /> {descriptionEl}
; @@ -295,14 +299,15 @@ export class DynamicField extends React.Component { this.onChange(newText); }} disabled={disabled} onBlur={this.onBlur} - errorMessage={customNumberErrorMessage} - min={minimumValue} + errorMessage={errorText || customNumberErrorMessage} + min={minimumValue} max={maximumValue} /> {descriptionEl}
; @@ -319,8 +324,8 @@ export class DynamicField extends React.Component { return date.toLocaleDateString(context.pageContext.cultureInfo.currentCultureName); }} - value={(changedValue !== null && changedValue !== "") ? changedValue : defaultValue} - onSelectDate={(newDate) => { this.onChange(newDate); }} + value={valueToDisplay !== undefined ? valueToDisplay : defaultValue} + onSelectDate={(newDate) => { this.onChange(newDate, true); }} disabled={disabled} firstDayOfWeek={firstDayOfWeek} />} @@ -330,8 +335,8 @@ export class DynamicField extends React.Component { return date.toLocaleDateString(context.pageContext.cultureInfo.currentCultureName); }} - value={(changedValue !== null && changedValue !== "") ? changedValue : defaultValue} - onChange={(newDate) => { this.onChange(newDate); }} + value={valueToDisplay !== undefined ? valueToDisplay : defaultValue} + onChange={(newDate) => { this.onChange(newDate, true); }} disabled={disabled} firstDayOfWeek={firstDayOfWeek} /> @@ -349,16 +354,18 @@ export class DynamicField extends React.Component { this.onChange(checkedvalue); }} + onChange={(e, checkedvalue) => { this.onChange(checkedvalue, true); }} disabled={disabled} /> {descriptionEl} {errorTextEl}
; - case 'User': + case 'User': { + const userValue = Boolean(changedValue) ? changedValue.map(cv => cv.secondaryText) : (value ? value : defaultValue); return
@@ -366,7 +373,7 @@ export class DynamicField extends React.Component { this.onChange(items); }} + onChange={(items) => { this.onChange(items, true); }} disabled={disabled} /> {descriptionEl} {errorTextEl}
; + } case 'UserMulti': return
@@ -389,7 +397,7 @@ export class DynamicField extends React.Component { this.onChange(items); }} + onChange={(items) => { this.onChange(items, true); }} disabled={disabled} /> {descriptionEl} @@ -414,13 +422,16 @@ export class DynamicField extends React.Component { this.onURLChange(newText, true); }} disabled={disabled} - onBlur={this.onBlur} /> + onBlur={this.onBlur} + /> { this.onURLChange(newText, false); }} @@ -487,14 +498,14 @@ export class DynamicField extends React.Component { this.onChange(newValue); }} + onChange={(newValue?: IPickerTerms) => { this.onChange(newValue, true); }} isTermSetSelectable={false} />
@@ -512,14 +523,14 @@ export class DynamicField extends React.Component { this.onChange(newValue); }} + onChange={(newValue?: IPickerTerms) => { this.onChange(newValue, true); }} isTermSetSelectable={false} />
{descriptionEl} @@ -548,12 +559,12 @@ export class DynamicField extends React.Component { const { - fieldDefaultValue, + defaultValue, onChanged, columnInternalName } = this.props; - let currValue = this.state.changedValue || fieldDefaultValue || { + let currValue = this.state.changedValue || defaultValue || { Url: '', Description: '' }; @@ -573,18 +584,18 @@ export class DynamicField extends React.Component { // eslint-disable-line @typescript-eslint/no-explicit-any + private onChange = (value: any, callValidation = false): void => { // eslint-disable-line @typescript-eslint/no-explicit-any const { onChanged, columnInternalName } = this.props; if (onChanged) { - onChanged(columnInternalName, value); + onChanged(columnInternalName, value, callValidation); } this.setState({ changedValue: value @@ -592,9 +603,10 @@ export class DynamicField extends React.Component { - if (this.state.changedValue === null && this.props.fieldDefaultValue === "") { + if (this.state.changedValue === null && this.props.defaultValue === "") { this.setState({ changedValue: "" }); } + this.props.onChanged(this.props.columnInternalName, this.state.changedValue, true); } private getRequiredErrorText = (): string => { @@ -666,15 +678,17 @@ export class DynamicField extends React.Component { + value.forEach(element => { selectedItemArr.push(element); }); } else { - selectedItemArr = !changedValue ? [] : isArray(changedValue) ? changedValue : [ changedValue ]; + // selectedItemArr = this.props.value; + selectedItemArr = !changedValue ? [] : + ( Array.isArray(changedValue) ? [ ...changedValue ] : [ changedValue] ); } if (item.selected) { @@ -688,7 +702,7 @@ export class DynamicField extends React.Component void; // eslint-disable-line @typescript-eslint/no-explicit-any - value?: any; // eslint-disable-line @typescript-eslint/no-explicit-any + + /** Specifies if a field should be filled in order to pass validation */ required: boolean; + + /** Specifies if a field should be disabled */ + disabled?: boolean; + + /** List Item Id, passed to various utility/helper functions to determine things like selected User UPN, Lookup text, Term labels etc. */ + listItemId?: number; + + /** The default value of the field. */ + defaultValue: any; // eslint-disable-line @typescript-eslint/no-explicit-any + + /** Holds a field value. Set on all fields in the form. */ + value?: any; // eslint-disable-line @typescript-eslint/no-explicit-any + + /** Fired by DynamicField when a field value is changed */ + onChanged?: (columnInternalName: string, newValue: any, validate: boolean, additionalData?: FieldChangeAdditionalData) => void; // eslint-disable-line @typescript-eslint/no-explicit-any + + /** Represents the value of the field as updated by the user. Only updated by fields when changed. */ newValue?: any; // eslint-disable-line @typescript-eslint/no-explicit-any - fieldType: string; - fieldTitle: string; - fieldDefaultValue: any; // eslint-disable-line @typescript-eslint/no-explicit-any - options?: IDropdownOption[]; + + /** Represents a stringified value of the field. Used in custom formatting and validation. */ + stringValue: any; // eslint-disable-line @typescript-eslint/no-explicit-any + + /** Holds additional properties that can be queried in validation. For example a Person column may be reference by both [$Person] and [$Person.email] */ + subPropertyValues?: Record; // eslint-disable-line @typescript-eslint/no-explicit-any + + /** If validation raises an error message, it can be stored against the field here for display by DynamicField */ + validationErrorMessage?: string; + + /** Field Term Set ID, used in Taxonomy / Metadata fields */ fieldTermSetId?: string; + + /** Field Anchor ID, used in Taxonomy / Metadata fields */ fieldAnchorId?: string; + + /** Lookup List ID, used in Lookup and User fields */ lookupListID?: string; + + /** Lookup Field. Represents the field used for Lookup values. */ lookupField?: string; - changedValue: any; // eslint-disable-line @typescript-eslint/no-explicit-any + + // changedValue: any; // eslint-disable-line @typescript-eslint/no-explicit-any + + /** Equivalent to HiddenListInternalName, used for Taxonomy Metadata fields */ hiddenFieldName?: string; + + /** Order of the field in the form */ Order: number; + + /** Used for files / image uploads */ + additionalData?: FieldChangeAdditionalData; + + // Related to various field types + options?: IDropdownOption[]; isRichText?: boolean; dateFormat?: DateFormat; firstDayOfWeek: number; - additionalData?: FieldChangeAdditionalData; principalType?: string; description?: string; maximumValue?: number; diff --git a/src/controls/fieldCollectionData/collectionDataItem/CollectionDataItem.tsx b/src/controls/fieldCollectionData/collectionDataItem/CollectionDataItem.tsx index d29fdaf51..ba4bd76d6 100644 --- a/src/controls/fieldCollectionData/collectionDataItem/CollectionDataItem.tsx +++ b/src/controls/fieldCollectionData/collectionDataItem/CollectionDataItem.tsx @@ -57,7 +57,7 @@ export class CollectionDataItem extends React.Component { // eslint-disable-line @typescript-eslint/no-explicit-any - + this.setState((prevState: ICollectionDataItemState): ICollectionDataItemState => { const { crntItem } = prevState; // Update the changed field @@ -93,7 +93,7 @@ export class CollectionDataItem extends React.Component { this.onValueChanged(field.id, date) }} formatDate={(date) => { return date ? date?.toLocaleDateString() : ""; }} - />; + />; case CustomCollectionFieldType.custom: if (field.onCustomRender) { return field.onCustomRender(field, item[field.id], this.onValueChanged, item, item.uniqueId, this.onCustomFieldValidation); @@ -537,7 +537,7 @@ export class CollectionDataItem extends React.Component value.key === item[field.id][i].key) === "undefined" ) { + if (typeof _comboBoxOptions.find(value => value.key === item[field.id][i].key) === "undefined") { _comboBoxOptions.push(item[field.id][i]); } } @@ -548,7 +548,7 @@ export class CollectionDataItem extends React.Component value.key === item[field.id].key) === "undefined" ) { + if (typeof _comboBoxOptions.find(value => value.key === item[field.id].key) === "undefined") { _comboBoxOptions.push(item[field.id]); } @@ -563,33 +563,33 @@ export class CollectionDataItem extends React.Component{ + onChange={async (event, option, index, value) => { if (field.multiSelect) { - this.onValueChangedComboBoxMulti(field.id, option, value); + this.onValueChangedComboBoxMulti(field.id, option, value) } else { - this.onValueChangedComboBoxSingle(field.id, option, value); + this.onValueChangedComboBoxSingle(field.id, option, value) } - } } - + }} />; + case CustomCollectionFieldType.peoplepicker: - _selectedUsers = item[field.id] !== null ? item[field.id]: [] ; + _selectedUsers = item[field.id] !== null ? item[field.id] : []; return { - const _selected: string[] = items.length === 0 ? null : items.map(({secondaryText}) => secondaryText); - this.onValueChanged(field.id, _selected) - } + const _selected: string[] = items.length === 0 ? null : items.map(({ secondaryText }) => secondaryText); + this.onValueChanged(field.id, _selected) + } } onGetErrorMessage={async (items: IPersonaProps[]) => await this.peoplepickerValidation(field, items, item)} @@ -604,7 +604,7 @@ export class CollectionDataItem extends React.Component this.onValueChanged(field.id, value)} deferredValidationTime={field.deferredValidationTime || field.deferredValidationTime >= 0 ? field.deferredValidationTime : 200} - onGetErrorMessage={async (value: string) => await this.fieldValidation(field, value) } + onGetErrorMessage={async (value: string) => await this.fieldValidation(field, value)} inputClassName="PropertyFieldCollectionData__panel__string-field" />; } } @@ -716,10 +716,10 @@ export class CollectionDataItem extends React.Component ) : ( - - - - ) + + + + ) }
diff --git a/src/controls/webPartTitle/WebPartTitle.tsx b/src/controls/webPartTitle/WebPartTitle.tsx index 5a30431d7..b4b57d0ae 100644 --- a/src/controls/webPartTitle/WebPartTitle.tsx +++ b/src/controls/webPartTitle/WebPartTitle.tsx @@ -54,7 +54,7 @@ export class WebPartTitle extends React.Component {
{ this.props.displayMode === DisplayMode.Edit && ( -