Skip to content

Commit 3cd6f18

Browse files
authored
Merge pull request #1672 from t0mgerman/dynamic-form-list-customization
New Dynamic Form Feature(s) - Custom Formatting and Validation, ControlsTestWebPart updates
2 parents f0f11aa + e027b1a commit 3cd6f18

35 files changed

+3249
-878
lines changed

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ obj
3535
*.tgz
3636

3737
# VSCode
38-
.vscode
38+
.vscode/*
39+
40+
# Included VSCode files
41+
!.vscode/example-tasks.json
42+
!.vscode/example-launch.json
3943

4044
# Documentation
4145
docs/documentation/site

.vscode/example-launch.json

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
/**
3+
* Populate and rename this file to launch.json to configure debugging
4+
*/
5+
"version": "0.2.0",
6+
"configurations": [
7+
{
8+
"name": "Hosted workbench (chrome)",
9+
"type": "chrome",
10+
"request": "launch",
11+
"url": "https://enter-your-SharePoint-site.sharepoint.com/sites/mySite/_layouts/15/workbench.aspx",
12+
"webRoot": "${workspaceRoot}",
13+
"sourceMaps": true,
14+
"sourceMapPathOverrides": {
15+
"webpack:///.././src/*": "${webRoot}/src/*",
16+
"webpack:///../../../src/*": "${webRoot}/src/*",
17+
"webpack:///../../../../src/*": "${webRoot}/src/*",
18+
"webpack:///../../../../../src/*": "${webRoot}/src/*"
19+
},
20+
"preLaunchTask": "npm: serve",
21+
"runtimeArgs": [
22+
"--remote-debugging-port=9222",
23+
]
24+
},
25+
{
26+
"name": "Hosted workbench (edge)",
27+
"type": "edge",
28+
"request": "launch",
29+
"url": "https://enter-your-SharePoint-site.sharepoint.com/sites/mySite/_layouts/15/workbench.aspx",
30+
"webRoot": "${workspaceRoot}",
31+
"sourceMaps": true,
32+
"sourceMapPathOverrides": {
33+
"webpack:///.././src/*": "${webRoot}/src/*",
34+
"webpack:///../../../src/*": "${webRoot}/src/*",
35+
"webpack:///../../../../src/*": "${webRoot}/src/*",
36+
"webpack:///../../../../../src/*": "${webRoot}/src/*"
37+
},
38+
"preLaunchTask": "npm: serve",
39+
"runtimeArgs": [
40+
"--remote-debugging-port=9222",
41+
]
42+
},
43+
]
44+
}

.vscode/example-tasks.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
/**
3+
* Populate and rename this file to launch.json to configure debugging
4+
*/
5+
"version": "2.0.0",
6+
"tasks": [
7+
{
8+
"type": "npm",
9+
"script": "serve",
10+
"isBackground": true,
11+
"problemMatcher": {
12+
"owner": "custom",
13+
"pattern": {
14+
"regexp": "."
15+
},
16+
"background": {
17+
"activeOnStart": true,
18+
"beginsPattern": "Starting 'bundle'",
19+
"endsPattern": "\\[\\sFinished\\s\\]"
20+
}
21+
},
22+
"label": "npm: serve",
23+
"detail": "gulp bundle --custom-serve --max_old_space_size=4096 && fast-serve",
24+
"group": {
25+
"kind": "build",
26+
"isDefault": true
27+
}
28+
},
29+
]
30+
}

src/common/SPEntities.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ export interface ISPField {
5656
LookupDisplayUrl?: string;
5757
TypeAsString?: string;
5858
ResultType?: string;
59+
ValidationFormula?: string;
60+
ValidationMessage?: string;
61+
MinimumValue?: number;
62+
MaximumValue?: number;
63+
CurrencyLocaleId?: number;
5964
}
6065

6166
/**
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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

Comments
 (0)