Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New Dynamic Form Feature(s) - Custom Formatting and Validation, ControlsTestWebPart updates #1672

Merged
merged 16 commits into from
Dec 2, 2023
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Refactor of DynamicForm, DynamicField, ControlsTestWebPart, added exp…
…ression validation and custom formatting renderer.
Tom German committed Oct 6, 2023
commit d5b0f427b42d92f58c37b673d4431c2031c70225
104 changes: 104 additions & 0 deletions src/common/utilities/CustomFormatting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import * as React from "react";
import { Icon } from "office-ui-fabric-react";
import { FormulaEvaluation } from "./FormulaEvaluation";
import { ASTNode } from "./FormulaEvaluation.types";
import { ICustomFormattingExpressionNode, ICustomFormattingNode } from "./ICustomFormatting";

/**
* 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 {
t0mgerman marked this conversation as resolved.
Show resolved Hide resolved

private _formulaEvaluator: FormulaEvaluation;

/**
*
* @param formulaEvaluator An instance of FormulaEvaluation used for evaluating expressions in custom formatting
*/
constructor(formulaEvaluator: FormulaEvaluation) {
this._formulaEvaluator = formulaEvaluator;
}

private convertCustomFormatExpressionNodes = (node: ICustomFormattingExpressionNode | string | number | boolean): ASTNode => {
if (typeof node !== "object") {
switch (typeof node) {
case "string":
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can combine both case in one

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 };
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
private evaluateCustomFormatContent = (content: ICustomFormattingExpressionNode | ICustomFormattingNode | string | number | boolean, context: any): string | number | boolean => {
if ((typeof content === "string" && content.charAt(0) !== "=") || typeof content === "number") return content;
if (typeof content === "string" && content.charAt(0) === "=") {
const result = this._formulaEvaluator.evaluate(content.substring(1), context);
return result;
}
if (typeof content === "object" && (Object.prototype.hasOwnProperty.call(content, "elmType"))) {
t0mgerman marked this conversation as resolved.
Show resolved Hide resolved
return this.renderCustomFormatContent(content as ICustomFormattingNode, context);
} else if (typeof content === "object" && (Object.prototype.hasOwnProperty.call(content, "operator"))) {
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, context);
}
return result;
}
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
public renderCustomFormatContent = (node: ICustomFormattingNode, context: any, rootEl: boolean = false): any => {
if (typeof node === "string" || typeof node === "number") return node;
// txtContent
let textContent: JSX.Element | string | undefined;
if (node.txtContent) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
textContent = this.evaluateCustomFormatContent(node.txtContent, context) as any;
}
// style
const styleProperties = {} as 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;
}
}
}
// attributes
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const attributes = {} as any;
if (node.attributes) {
for (const attribute in node.attributes) {
if (node.attributes[attribute]) {
let attributeName = attribute;
if (attributeName === "class") attributeName = "className";
attributes[attributeName] = this.evaluateCustomFormatContent(node.attributes[attribute], context) as string;
if (attributeName === "className" && rootEl) {
attributes[attributeName] = `${attributes[attributeName]} sp-field-customFormatter`;
}
}
}
}
// children
let children: (JSX.Element | string | number | boolean | undefined)[] = [];
if (attributes.iconName) {
const icon = React.createElement(Icon, { iconName: attributes.iconName });
children.push(icon);
}
if (node.children) {
children = node.children.map(c => this.evaluateCustomFormatContent(c, context));
}
// render
const el = React.createElement(node.elmType, { style: styleProperties, ...attributes }, textContent, ...children);
return el;
}
}
41 changes: 22 additions & 19 deletions src/common/utilities/FormulaEvaluation.ts
Original file line number Diff line number Diff line change
@@ -4,25 +4,27 @@ import { IContext } from "../Interfaces";
import { ASTNode, ArrayLiteralNode, Token, TokenType, ValidFuncNames } from "./FormulaEvaluation.types";

export class FormulaEvaluation {
constructor(context: IContext) {
private webUrl: string;
constructor(context: IContext, webUrlOverride?: string) {
sp.setup({ pageContext: context.pageContext });
this.webUrl = webUrlOverride || context.pageContext.web.absoluteUrl;
}

/** Evaluates a formula expression and returns the result, with optional context object for variables */
public async evaluate(expression: string, context: { [key: string]: any } = {}): Promise<any> {
public evaluate(expression: string, context: { [key: string]: any } = {}): any {
const tokens: Token[] = this._tokenize(expression, context);
const postfix: Token[] = this._shuntingYard(tokens);
const ast: ASTNode = this._buildAST(postfix);
return await this._evaluateAST(ast, context);
return this.evaluateASTNode(ast, context);
}

/** Tokenizes an expression into a list of tokens (primatives, operators, variables, function names, arrays etc) */
private _tokenize(expression: string, context: { [key: string]: any }): Token[] {
// Each pattern captures a different token type
// and are matched in order
const patterns: [RegExp, TokenType][] = [
t0mgerman marked this conversation as resolved.
Show resolved Hide resolved
[/^\[\$?[a-zA-Z_][a-zA-Z_0-9]*\]/, "VARIABLE"], // [$variable]
[/^@[a-zA-Z_][a-zA-Z_0-9]*/, "VARIABLE"], // @variable
[/^\[\$?[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
@@ -285,14 +287,17 @@ export class FormulaEvaluation {
return stack[0] as ASTNode;
}

private async _evaluateAST(node: ASTNode | ArrayLiteralNode | string | number, context: { [key: string]: any }): Promise<any> {
public evaluateASTNode(node: ASTNode | ArrayLiteralNode | string | number, context: { [key: string]: any }): any {

if (!node) return 0;

if (typeof node === "object" && !(Object.prototype.hasOwnProperty.call(node, 'type') && Object.prototype.hasOwnProperty.call(node, 'value'))) {
return node;
}

// Each element in an array literal is evaluated recursively
if (node instanceof ArrayLiteralNode) {
const evaluatedElementsPromises = (node as ArrayLiteralNode).elements.map(element => this._evaluateAST(element, context));
const evaluatedElements = await Promise.all(evaluatedElementsPromises);
const evaluatedElements = (node as ArrayLiteralNode).elements.map(element => this.evaluateASTNode(element, context));
return evaluatedElements;
}

@@ -322,14 +327,14 @@ export class FormulaEvaluation {

// 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;
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" && ["+", "-", "*", "/", "==", "!=", ">", "<", ">=", "<=", "&&", "||", "%", "&", "|"].includes(node.value as string) && node.operands) {

const leftValue = await this._evaluateAST(node.operands[0], context);
const rightValue = await this._evaluateAST(node.operands[1], context);
const leftValue = this.evaluateASTNode(node.operands[0], context);
const rightValue = this.evaluateASTNode(node.operands[1], context);

if (typeof leftValue === "string" || typeof rightValue === "string") {
// Throw an error if the operator is not valid for strings
@@ -338,7 +343,7 @@ export class FormulaEvaluation {
}
// Concatenate strings if either operand is a string
if (node.value === "+") {
return leftValue.toString() + rightValue.toString();
return (leftValue || "").toString() + (rightValue || "").toString();
}
}

@@ -364,8 +369,7 @@ export class FormulaEvaluation {
// Evaluation of function nodes is handled here:

if (node.type === "FUNCTION" && node.operands) {
const funcArgsPromises = node.operands.map(arg => this._evaluateAST(arg, context));
const funcArgs = await Promise.all(funcArgsPromises);
const funcArgs = node.operands.map(arg => this.evaluateASTNode(arg, context));

switch (node.value) {

@@ -543,7 +547,7 @@ export class FormulaEvaluation {
}
case 'getThumbnailImage': {
const imageUrl = funcArgs[0];
const thumbnailImage = await this._getSharePointThumbnailUrl(imageUrl);
const thumbnailImage = this._getSharePointThumbnailUrl(imageUrl);
return thumbnailImage;
}

@@ -570,7 +574,7 @@ export class FormulaEvaluation {
}
else {
// treat as char Array
const value = await this._evaluateAST(array, context);
const value = this.evaluateASTNode(array, context);
return value.toString().length;
}
}
@@ -602,9 +606,8 @@ export class FormulaEvaluation {
const [filenameNoExt, ext] = filename.split('.');
return `${url}/_t/${filenameNoExt}_${ext}.jpg`;
}
private async _getUserImageUrl(userEmail: string): Promise<string> {
const user = await sp.web.ensureUser(userEmail);
return (user.data as any).PictureUrl || '';
private _getUserImageUrl(userEmail: string): string {
return `${this.webUrl}/_layouts/15/userphoto.aspx?size=L&username=${userEmail}`
}
}

Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { CSSProperties } from "react";

interface ICustomFormattingNode {
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 {
8 changes: 8 additions & 0 deletions src/controls/dynamicForm/DynamicForm.module.scss
Original file line number Diff line number Diff line change
@@ -189,4 +189,12 @@ h2.sectionTitle {
border-left-width: 0;
border-right-width: 0;
clear: both;
}

:global {
.sp-field-customFormatter {
min-height: inherit;
display: flex;
align-items: center;
}
}
Loading