Skip to content

Commit e4cf220

Browse files
author
Tom German
committed
Applied recommended changes following review
1 parent 10095b9 commit e4cf220

File tree

4 files changed

+181
-119
lines changed

4 files changed

+181
-119
lines changed

src/common/utilities/CustomFormatting.ts

Lines changed: 77 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as React from "react";
22
import { Icon } from "office-ui-fabric-react";
33
import { FormulaEvaluation } from "./FormulaEvaluation";
4-
import { ASTNode } from "./FormulaEvaluation.types";
4+
import { ASTNode, Context } from "./FormulaEvaluation.types";
55
import { ICustomFormattingExpressionNode, ICustomFormattingNode } from "./ICustomFormatting";
66

77
/**
@@ -13,13 +13,18 @@ export default class CustomFormattingHelper {
1313
private _formulaEvaluator: FormulaEvaluation;
1414

1515
/**
16-
*
16+
* Custom Formatting Helper / Renderer
1717
* @param formulaEvaluator An instance of FormulaEvaluation used for evaluating expressions in custom formatting
1818
*/
1919
constructor(formulaEvaluator: FormulaEvaluation) {
2020
this._formulaEvaluator = formulaEvaluator;
2121
}
2222

23+
/**
24+
* The Formula Evaluator expects an ASTNode to be passed to it for evaluation. This method converts expressions
25+
* described by the interface ICustomFormattingExpressionNode to ASTNodes.
26+
* @param node An ICustomFormattingExpressionNode to be converted to an ASTNode
27+
*/
2328
private convertCustomFormatExpressionNodes = (node: ICustomFormattingExpressionNode | string | number | boolean): ASTNode => {
2429
if (typeof node !== "object") {
2530
switch (typeof node) {
@@ -36,69 +41,116 @@ export default class CustomFormattingHelper {
3641
return { type: "operator", value: operator, operands };
3742
}
3843

39-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
40-
private evaluateCustomFormatContent = (content: ICustomFormattingExpressionNode | ICustomFormattingNode | string | number | boolean, context: any): string | number | boolean => {
44+
/**
45+
* Given a single custom formatting expression, node or element, this method evaluates the expression and returns the result
46+
* @param content An object, expression or literal value to be evaluated
47+
* @param context A context object containing values / variables to be used in the evaluation
48+
* @returns
49+
*/
50+
private evaluateCustomFormatContent = (content: ICustomFormattingExpressionNode | ICustomFormattingNode | string | number | boolean, context: Context): JSX.Element | string | number | boolean => {
51+
52+
// If content is a string or number, it is a literal value and should be returned as-is
4153
if ((typeof content === "string" && content.charAt(0) !== "=") || typeof content === "number") return content;
54+
55+
// If content is a string beginning with '=' it is a formula/expression, and should be evaluated
4256
if (typeof content === "string" && content.charAt(0) === "=") {
4357
const result = this._formulaEvaluator.evaluate(content.substring(1), context);
4458
return result;
4559
}
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);
60+
61+
// If content is an object, it is either further custom formatting described by an ICustomFormattingNode,
62+
// or an expression to be evaluated - as described by an ICustomFormattingExpressionNode
63+
64+
if (typeof content === "object") {
65+
66+
if (Object.prototype.hasOwnProperty.call(content, "elmType")) {
67+
68+
// Custom Formatting Content
69+
return this.renderCustomFormatContent(content as ICustomFormattingNode, context);
70+
71+
} else if (Object.prototype.hasOwnProperty.call(content, "operator")) {
72+
73+
// Expression to be evaluated
74+
const astNode = this.convertCustomFormatExpressionNodes(content as ICustomFormattingExpressionNode);
75+
const result = this._formulaEvaluator.evaluateASTNode(astNode, context);
76+
if (typeof result === "object" && Object.prototype.hasOwnProperty.call(result, "elmType")) {
77+
return this.renderCustomFormatContent(result, context);
78+
}
79+
return result;
80+
5381
}
54-
return result;
55-
}
82+
}
5683
}
5784

5885
// eslint-disable-next-line @typescript-eslint/no-explicit-any
59-
public renderCustomFormatContent = (node: ICustomFormattingNode, context: any, rootEl: boolean = false): any => {
86+
public renderCustomFormatContent = (node: ICustomFormattingNode, context: Context, rootEl: boolean = false): JSX.Element | string | number => {
87+
88+
// We don't want attempts to render custom format content to kill the component or web part,
89+
// so we wrap the entire method in a try/catch block, log errors and return null if an error occurs
6090
try {
91+
92+
// If node is a string or number, it is a literal value and should be returned as-is
6193
if (typeof node === "string" || typeof node === "number") return node;
62-
// txtContent
63-
let textContent: JSX.Element | string | undefined;
94+
95+
// Custom formatting nodes / elements may have a txtContent property, which represents the inner
96+
// content of a HTML element. This can be a string literal, or another expression to be evaluated:
97+
let textContent: JSX.Element | string | number | boolean | undefined;
6498
if (node.txtContent) {
65-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
66-
textContent = this.evaluateCustomFormatContent(node.txtContent, context) as any;
99+
textContent = this.evaluateCustomFormatContent(node.txtContent, context);
67100
}
68-
// style
69-
const styleProperties = {} as React.CSSProperties;
101+
102+
// Custom formatting nodes / elements may have a style property, which contains the style rules
103+
// to be applied to the resulting HTML element. Rule values can be string literals or another expression
104+
// to be evaluated:
105+
const styleProperties: React.CSSProperties = {};
70106
if (node.style) {
71107
for (const styleAttribute in node.style) {
72108
if (node.style[styleAttribute]) {
73109
styleProperties[styleAttribute] = this.evaluateCustomFormatContent(node.style[styleAttribute], context) as string;
74110
}
75111
}
76112
}
77-
// attributes
78-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
79-
const attributes = {} as any;
113+
114+
// Custom formatting nodes / elements may have an attributes property, which represents the HTML attributes
115+
// to be applied to the resulting HTML element. Attribute values can be string literals or another expression
116+
// to be evaluated:
117+
const attributes = {} as Record<string, string>;
80118
if (node.attributes) {
81119
for (const attribute in node.attributes) {
82120
if (node.attributes[attribute]) {
83121
let attributeName = attribute;
122+
123+
// Because we're using React to render the HTML content, we need to rename the 'class' attribute
84124
if (attributeName === "class") attributeName = "className";
125+
126+
// Evaluation
85127
attributes[attributeName] = this.evaluateCustomFormatContent(node.attributes[attribute], context) as string;
128+
129+
// Add the 'sp-field-customFormatter' class to the root element
86130
if (attributeName === "className" && rootEl) {
87131
attributes[attributeName] = `${attributes[attributeName]} sp-field-customFormatter`;
88132
}
89133
}
90134
}
91135
}
92-
// children
136+
137+
// Custom formatting nodes / elements may have children. These are likely to be further custom formatting
93138
let children: (JSX.Element | string | number | boolean | undefined)[] = [];
139+
140+
// If the node has an iconName property, we'll render an Icon component as the first child.
141+
// SharePoint uses CSS to apply the icon in a ::before rule, but we can't count on the global selector for iconName
142+
// being present on the page, so we'll add it as a child instead:
94143
if (attributes.iconName) {
95144
const icon = React.createElement(Icon, { iconName: attributes.iconName });
96145
children.push(icon);
97146
}
147+
148+
// Each child object is evaluated recursively and added to the children array
98149
if (node.children) {
99150
children = node.children.map(c => this.evaluateCustomFormatContent(c, context));
100151
}
101-
// render
152+
153+
// The resulting HTML element is returned to the callee using React.createElement
102154
const el = React.createElement(node.elmType, { style: styleProperties, ...attributes }, textContent, ...children);
103155
return el;
104156
} catch (error) {

0 commit comments

Comments
 (0)