1+ import * as React from "react" ;
2+ import { Icon } from "@fluentui/react/lib/Icon" ;
3+ import { FormulaEvaluation } from "./FormulaEvaluation" ;
4+ import { ASTNode , Context } from "./FormulaEvaluation.types" ;
5+ import { ICustomFormattingExpressionNode , ICustomFormattingNode } from "./ICustomFormatting" ;
6+
7+ type CustomFormatResult = string | number | boolean | JSX . Element | ICustomFormattingNode ;
8+
9+ /**
10+ * A class that provides helper methods for custom formatting
11+ * See: https://learn.microsoft.com/en-us/sharepoint/dev/declarative-customization/formatting-syntax-reference
12+ */
13+ export default class CustomFormattingHelper {
14+
15+ private _formulaEvaluator : FormulaEvaluation ;
16+
17+ /**
18+ * Custom Formatting Helper / Renderer
19+ * @param formulaEvaluator An instance of FormulaEvaluation used for evaluating expressions in custom formatting
20+ */
21+ constructor ( formulaEvaluator : FormulaEvaluation ) {
22+ this . _formulaEvaluator = formulaEvaluator ;
23+ }
24+
25+ /**
26+ * The Formula Evaluator expects an ASTNode to be passed to it for evaluation. This method converts expressions
27+ * described by the interface ICustomFormattingExpressionNode to ASTNodes.
28+ * @param node An ICustomFormattingExpressionNode to be converted to an ASTNode
29+ */
30+ private convertCustomFormatExpressionNodes = ( node : ICustomFormattingExpressionNode | string | number | boolean ) : ASTNode => {
31+ if ( typeof node !== "object" ) {
32+ switch ( typeof node ) {
33+ case "string" :
34+ return { type : "string" , value : node } ;
35+ case "number" :
36+ return { type : "number" , value : node } ;
37+ case "boolean" :
38+ return { type : "booelan" , value : node ? 1 : 0 } ;
39+ }
40+ }
41+ const operator = node . operator ;
42+ const operands = node . operands . map ( o => this . convertCustomFormatExpressionNodes ( o ) ) ;
43+ return { type : "operator" , value : operator , operands } ;
44+ }
45+
46+ /**
47+ * Given a single custom formatting expression, node or element, this method evaluates the expression and returns the result
48+ * @param content An object, expression or literal value to be evaluated
49+ * @param context A context object containing values / variables to be used in the evaluation
50+ * @returns
51+ */
52+ private evaluateCustomFormatContent = ( content : ICustomFormattingExpressionNode | ICustomFormattingNode | string | number | boolean , context : Context ) : CustomFormatResult => {
53+
54+ // If content is a string or number, it is a literal value and should be returned as-is
55+ if ( ( typeof content === "string" && content . charAt ( 0 ) !== "=" ) || typeof content === "number" ) return content ;
56+
57+ // If content is a string beginning with '=' it is a formula/expression, and should be evaluated
58+ if ( typeof content === "string" && content . charAt ( 0 ) === "=" ) {
59+ const result = this . _formulaEvaluator . evaluate ( content . substring ( 1 ) , context ) ;
60+ return result as CustomFormatResult ;
61+ }
62+
63+ // If content is an object, it is either further custom formatting described by an ICustomFormattingNode,
64+ // or an expression to be evaluated - as described by an ICustomFormattingExpressionNode
65+
66+ if ( typeof content === "object" ) {
67+
68+ if ( Object . prototype . hasOwnProperty . call ( content , "elmType" ) ) {
69+
70+ // Custom Formatting Content
71+ return this . renderCustomFormatContent ( content as ICustomFormattingNode , context ) ;
72+
73+ } else if ( Object . prototype . hasOwnProperty . call ( content , "operator" ) ) {
74+
75+ // Expression to be evaluated
76+ const astNode = this . convertCustomFormatExpressionNodes ( content as ICustomFormattingExpressionNode ) ;
77+ const result = this . _formulaEvaluator . evaluateASTNode ( astNode , context ) ;
78+ if ( typeof result === "object" && Object . prototype . hasOwnProperty . call ( result , "elmType" ) ) {
79+ return this . renderCustomFormatContent ( result as ICustomFormattingNode , context ) ;
80+ }
81+ return result as CustomFormatResult ;
82+
83+ }
84+ }
85+ }
86+
87+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
88+ public renderCustomFormatContent = ( node : ICustomFormattingNode , context : Context , rootEl : boolean = false ) : JSX . Element | string | number => {
89+
90+ // We don't want attempts to render custom format content to kill the component or web part,
91+ // so we wrap the entire method in a try/catch block, log errors and return null if an error occurs
92+ try {
93+
94+ // If node is a string or number, it is a literal value and should be returned as-is
95+ if ( typeof node === "string" || typeof node === "number" ) return node ;
96+
97+ // Custom formatting nodes / elements may have a txtContent property, which represents the inner
98+ // content of a HTML element. This can be a string literal, or another expression to be evaluated:
99+ let textContent : CustomFormatResult | undefined ;
100+ if ( node . txtContent ) {
101+ textContent = this . evaluateCustomFormatContent ( node . txtContent , context ) ;
102+ }
103+
104+ // Custom formatting nodes / elements may have a style property, which contains the style rules
105+ // to be applied to the resulting HTML element. Rule values can be string literals or another expression
106+ // to be evaluated:
107+ const styleProperties : React . CSSProperties = { } ;
108+ if ( node . style ) {
109+ for ( const styleAttribute in node . style ) {
110+ if ( node . style [ styleAttribute ] ) {
111+ styleProperties [ styleAttribute ] = this . evaluateCustomFormatContent ( node . style [ styleAttribute ] , context ) as string ;
112+ }
113+ }
114+ }
115+
116+ // Custom formatting nodes / elements may have an attributes property, which represents the HTML attributes
117+ // to be applied to the resulting HTML element. Attribute values can be string literals or another expression
118+ // to be evaluated:
119+ const attributes = { } as Record < string , string > ;
120+ if ( node . attributes ) {
121+ for ( const attribute in node . attributes ) {
122+ if ( node . attributes [ attribute ] ) {
123+ let attributeName = attribute ;
124+
125+ // Because we're using React to render the HTML content, we need to rename the 'class' attribute
126+ if ( attributeName === "class" ) attributeName = "className" ;
127+
128+ // Evaluation
129+ attributes [ attributeName ] = this . evaluateCustomFormatContent ( node . attributes [ attribute ] , context ) as string ;
130+
131+ // Add the 'sp-field-customFormatter' class to the root element
132+ if ( attributeName === "className" && rootEl ) {
133+ attributes [ attributeName ] = `${ attributes [ attributeName ] } sp-field-customFormatter` ;
134+ }
135+ }
136+ }
137+ }
138+
139+ // Custom formatting nodes / elements may have children. These are likely to be further custom formatting
140+ let children : ( CustomFormatResult ) [ ] = [ ] ;
141+
142+ // If the node has an iconName property, we'll render an Icon component as the first child.
143+ // SharePoint uses CSS to apply the icon in a ::before rule, but we can't count on the global selector for iconName
144+ // being present on the page, so we'll add it as a child instead:
145+ if ( attributes . iconName ) {
146+ const icon = React . createElement ( Icon , { iconName : attributes . iconName } ) ;
147+ children . push ( icon ) ;
148+ }
149+
150+ // Each child object is evaluated recursively and added to the children array
151+ if ( node . children ) {
152+ children = node . children . map ( c => this . evaluateCustomFormatContent ( c , context ) ) ;
153+ }
154+
155+ // The resulting HTML element is returned to the callee using React.createElement
156+ const el = React . createElement ( node . elmType , { style : styleProperties , ...attributes } , textContent , ...children ) ;
157+ return el ;
158+ } catch ( error ) {
159+ console . error ( 'Unable to render custom formatted content' , error ) ;
160+ return null ;
161+ }
162+ }
163+ }
0 commit comments