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