Skip to content

Commit d5b0f42

Browse files
author
Tom German
committed
Refactor of DynamicForm, DynamicField, ControlsTestWebPart, added expression validation and custom formatting renderer.
1 parent f02dbb8 commit d5b0f42

File tree

12 files changed

+733
-650
lines changed

12 files changed

+733
-650
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import * as React from "react";
2+
import { Icon } from "office-ui-fabric-react";
3+
import { FormulaEvaluation } from "./FormulaEvaluation";
4+
import { ASTNode } from "./FormulaEvaluation.types";
5+
import { ICustomFormattingExpressionNode, ICustomFormattingNode } from "./ICustomFormatting";
6+
7+
/**
8+
* A class that provides helper methods for custom formatting
9+
* See: https://learn.microsoft.com/en-us/sharepoint/dev/declarative-customization/formatting-syntax-reference
10+
*/
11+
export default class CustomFormattingHelper {
12+
13+
private _formulaEvaluator: FormulaEvaluation;
14+
15+
/**
16+
*
17+
* @param formulaEvaluator An instance of FormulaEvaluation used for evaluating expressions in custom formatting
18+
*/
19+
constructor(formulaEvaluator: FormulaEvaluation) {
20+
this._formulaEvaluator = formulaEvaluator;
21+
}
22+
23+
private convertCustomFormatExpressionNodes = (node: ICustomFormattingExpressionNode | string | number | boolean): ASTNode => {
24+
if (typeof node !== "object") {
25+
switch (typeof node) {
26+
case "string":
27+
return { type: "string", value: node };
28+
case "number":
29+
return { type: "number", value: node };
30+
case "boolean":
31+
return { type: "booelan", value: node ? 1 : 0 };
32+
}
33+
}
34+
const operator = node.operator;
35+
const operands = node.operands.map(o => this.convertCustomFormatExpressionNodes(o));
36+
return { type: "operator", value: operator, operands };
37+
}
38+
39+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
40+
private evaluateCustomFormatContent = (content: ICustomFormattingExpressionNode | ICustomFormattingNode | string | number | boolean, context: any): string | number | boolean => {
41+
if ((typeof content === "string" && content.charAt(0) !== "=") || typeof content === "number") return content;
42+
if (typeof content === "string" && content.charAt(0) === "=") {
43+
const result = this._formulaEvaluator.evaluate(content.substring(1), context);
44+
return result;
45+
}
46+
if (typeof content === "object" && (Object.prototype.hasOwnProperty.call(content, "elmType"))) {
47+
return this.renderCustomFormatContent(content as ICustomFormattingNode, context);
48+
} else if (typeof content === "object" && (Object.prototype.hasOwnProperty.call(content, "operator"))) {
49+
const astNode = this.convertCustomFormatExpressionNodes(content as ICustomFormattingExpressionNode);
50+
const result = this._formulaEvaluator.evaluateASTNode(astNode, context);
51+
if (typeof result === "object" && Object.prototype.hasOwnProperty.call(result, "elmType")) {
52+
return this.renderCustomFormatContent(result, context);
53+
}
54+
return result;
55+
}
56+
}
57+
58+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
59+
public renderCustomFormatContent = (node: ICustomFormattingNode, context: any, rootEl: boolean = false): any => {
60+
if (typeof node === "string" || typeof node === "number") return node;
61+
// txtContent
62+
let textContent: JSX.Element | string | undefined;
63+
if (node.txtContent) {
64+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
65+
textContent = this.evaluateCustomFormatContent(node.txtContent, context) as any;
66+
}
67+
// style
68+
const styleProperties = {} as React.CSSProperties;
69+
if (node.style) {
70+
for (const styleAttribute in node.style) {
71+
if (node.style[styleAttribute]) {
72+
styleProperties[styleAttribute] = this.evaluateCustomFormatContent(node.style[styleAttribute], context) as string;
73+
}
74+
}
75+
}
76+
// attributes
77+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
78+
const attributes = {} as any;
79+
if (node.attributes) {
80+
for (const attribute in node.attributes) {
81+
if (node.attributes[attribute]) {
82+
let attributeName = attribute;
83+
if (attributeName === "class") attributeName = "className";
84+
attributes[attributeName] = this.evaluateCustomFormatContent(node.attributes[attribute], context) as string;
85+
if (attributeName === "className" && rootEl) {
86+
attributes[attributeName] = `${attributes[attributeName]} sp-field-customFormatter`;
87+
}
88+
}
89+
}
90+
}
91+
// children
92+
let children: (JSX.Element | string | number | boolean | undefined)[] = [];
93+
if (attributes.iconName) {
94+
const icon = React.createElement(Icon, { iconName: attributes.iconName });
95+
children.push(icon);
96+
}
97+
if (node.children) {
98+
children = node.children.map(c => this.evaluateCustomFormatContent(c, context));
99+
}
100+
// render
101+
const el = React.createElement(node.elmType, { style: styleProperties, ...attributes }, textContent, ...children);
102+
return el;
103+
}
104+
}

src/common/utilities/FormulaEvaluation.ts

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,27 @@ import { IContext } from "../Interfaces";
44
import { ASTNode, ArrayLiteralNode, Token, TokenType, ValidFuncNames } from "./FormulaEvaluation.types";
55

66
export class FormulaEvaluation {
7-
constructor(context: IContext) {
7+
private webUrl: string;
8+
constructor(context: IContext, webUrlOverride?: string) {
89
sp.setup({ pageContext: context.pageContext });
10+
this.webUrl = webUrlOverride || context.pageContext.web.absoluteUrl;
911
}
1012

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

1921
/** Tokenizes an expression into a list of tokens (primatives, operators, variables, function names, arrays etc) */
2022
private _tokenize(expression: string, context: { [key: string]: any }): Token[] {
2123
// Each pattern captures a different token type
2224
// and are matched in order
2325
const patterns: [RegExp, TokenType][] = [
24-
[/^\[\$?[a-zA-Z_][a-zA-Z_0-9]*\]/, "VARIABLE"], // [$variable]
25-
[/^@[a-zA-Z_][a-zA-Z_0-9]*/, "VARIABLE"], // @variable
26+
[/^\[\$?[a-zA-Z_][a-zA-Z_0-9.]*\]/, "VARIABLE"], // [$variable]
27+
[/^@[a-zA-Z_][a-zA-Z_0-9.]*/, "VARIABLE"], // @variable
2628
[/^[0-9]+(?:\.[0-9]+)?/, "NUMBER"], // Numeric literals
2729
[/^"([^"]*)"/, "STRING"], // Match double-quoted strings
2830
[/^'([^']*)'/, "STRING"], // Match single-quoted strings
@@ -285,14 +287,17 @@ export class FormulaEvaluation {
285287
return stack[0] as ASTNode;
286288
}
287289

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

290292
if (!node) return 0;
291293

294+
if (typeof node === "object" && !(Object.prototype.hasOwnProperty.call(node, 'type') && Object.prototype.hasOwnProperty.call(node, 'value'))) {
295+
return node;
296+
}
297+
292298
// Each element in an array literal is evaluated recursively
293299
if (node instanceof ArrayLiteralNode) {
294-
const evaluatedElementsPromises = (node as ArrayLiteralNode).elements.map(element => this._evaluateAST(element, context));
295-
const evaluatedElements = await Promise.all(evaluatedElementsPromises);
300+
const evaluatedElements = (node as ArrayLiteralNode).elements.map(element => this.evaluateASTNode(element, context));
296301
return evaluatedElements;
297302
}
298303

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

323328
// VARIABLE nodes are looked up in the context object and returned
324329
if (node.type === "VARIABLE") {
325-
return context[(node.value as string).replace(/^[[@]?\$?([a-zA-Z_][a-zA-Z_0-9]*)\]?/, '$1')] ?? null;
330+
return context[(node.value as string).replace(/^[[@]?\$?([a-zA-Z_][a-zA-Z_0-9.]*)\]?/, '$1')] ?? null;
326331
}
327332

328333
// OPERATOR nodes have their OPERANDS evaluated recursively, with the operator applied to the results
329334
if (node.type === "OPERATOR" && ["+", "-", "*", "/", "==", "!=", ">", "<", ">=", "<=", "&&", "||", "%", "&", "|"].includes(node.value as string) && node.operands) {
330335

331-
const leftValue = await this._evaluateAST(node.operands[0], context);
332-
const rightValue = await this._evaluateAST(node.operands[1], context);
336+
const leftValue = this.evaluateASTNode(node.operands[0], context);
337+
const rightValue = this.evaluateASTNode(node.operands[1], context);
333338

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

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

366371
if (node.type === "FUNCTION" && node.operands) {
367-
const funcArgsPromises = node.operands.map(arg => this._evaluateAST(arg, context));
368-
const funcArgs = await Promise.all(funcArgsPromises);
372+
const funcArgs = node.operands.map(arg => this.evaluateASTNode(arg, context));
369373

370374
switch (node.value) {
371375

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

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

src/controls/dynamicForm/ICustomFormatting.ts renamed to src/common/utilities/ICustomFormatting.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import { CSSProperties } from "react";
22

3-
interface ICustomFormattingNode {
3+
export interface ICustomFormattingExpressionNode {
4+
operator: string;
5+
operands: (string | number | ICustomFormattingExpressionNode)[];
6+
}
7+
8+
export interface ICustomFormattingNode {
49
elmType: keyof HTMLElementTagNameMap;
10+
iconName: string;
511
style: CSSProperties;
12+
attributes?: {
13+
[key: string]: string;
14+
};
615
children?: ICustomFormattingNode[];
16+
txtContent?: string;
717
}
818

919
export interface ICustomFormattingBodySection {

src/controls/dynamicForm/DynamicForm.module.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,12 @@ h2.sectionTitle {
189189
border-left-width: 0;
190190
border-right-width: 0;
191191
clear: both;
192+
}
193+
194+
:global {
195+
.sp-field-customFormatter {
196+
min-height: inherit;
197+
display: flex;
198+
align-items: center;
199+
}
192200
}

0 commit comments

Comments
 (0)