Skip to content

Commit

Permalink
Merge pull request #1672 from t0mgerman/dynamic-form-list-customization
Browse files Browse the repository at this point in the history
New Dynamic Form Feature(s) - Custom Formatting and Validation, ControlsTestWebPart updates
  • Loading branch information
AJIXuMuK authored Dec 2, 2023
2 parents f0f11aa + e027b1a commit 3cd6f18
Show file tree
Hide file tree
Showing 35 changed files with 3,249 additions and 878 deletions.
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ obj
*.tgz

# VSCode
.vscode
.vscode/*

# Included VSCode files
!.vscode/example-tasks.json
!.vscode/example-launch.json

# Documentation
docs/documentation/site
Expand Down
44 changes: 44 additions & 0 deletions .vscode/example-launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
/**
* Populate and rename this file to launch.json to configure debugging
*/
"version": "0.2.0",
"configurations": [
{
"name": "Hosted workbench (chrome)",
"type": "chrome",
"request": "launch",
"url": "https://enter-your-SharePoint-site.sharepoint.com/sites/mySite/_layouts/15/workbench.aspx",
"webRoot": "${workspaceRoot}",
"sourceMaps": true,
"sourceMapPathOverrides": {
"webpack:///.././src/*": "${webRoot}/src/*",
"webpack:///../../../src/*": "${webRoot}/src/*",
"webpack:///../../../../src/*": "${webRoot}/src/*",
"webpack:///../../../../../src/*": "${webRoot}/src/*"
},
"preLaunchTask": "npm: serve",
"runtimeArgs": [
"--remote-debugging-port=9222",
]
},
{
"name": "Hosted workbench (edge)",
"type": "edge",
"request": "launch",
"url": "https://enter-your-SharePoint-site.sharepoint.com/sites/mySite/_layouts/15/workbench.aspx",
"webRoot": "${workspaceRoot}",
"sourceMaps": true,
"sourceMapPathOverrides": {
"webpack:///.././src/*": "${webRoot}/src/*",
"webpack:///../../../src/*": "${webRoot}/src/*",
"webpack:///../../../../src/*": "${webRoot}/src/*",
"webpack:///../../../../../src/*": "${webRoot}/src/*"
},
"preLaunchTask": "npm: serve",
"runtimeArgs": [
"--remote-debugging-port=9222",
]
},
]
}
30 changes: 30 additions & 0 deletions .vscode/example-tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
/**
* Populate and rename this file to launch.json to configure debugging
*/
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "serve",
"isBackground": true,
"problemMatcher": {
"owner": "custom",
"pattern": {
"regexp": "."
},
"background": {
"activeOnStart": true,
"beginsPattern": "Starting 'bundle'",
"endsPattern": "\\[\\sFinished\\s\\]"
}
},
"label": "npm: serve",
"detail": "gulp bundle --custom-serve --max_old_space_size=4096 && fast-serve",
"group": {
"kind": "build",
"isDefault": true
}
},
]
}
5 changes: 5 additions & 0 deletions src/common/SPEntities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export interface ISPField {
LookupDisplayUrl?: string;
TypeAsString?: string;
ResultType?: string;
ValidationFormula?: string;
ValidationMessage?: string;
MinimumValue?: number;
MaximumValue?: number;
CurrencyLocaleId?: number;
}

/**
Expand Down
163 changes: 163 additions & 0 deletions src/common/utilities/CustomFormatting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import * as React from "react";
import { Icon } from "@fluentui/react/lib/Icon";
import { FormulaEvaluation } from "./FormulaEvaluation";
import { ASTNode, Context } from "./FormulaEvaluation.types";
import { ICustomFormattingExpressionNode, ICustomFormattingNode } from "./ICustomFormatting";

type CustomFormatResult = string | number | boolean | JSX.Element | ICustomFormattingNode;

/**
* A class that provides helper methods for custom formatting
* See: https://learn.microsoft.com/en-us/sharepoint/dev/declarative-customization/formatting-syntax-reference
*/
export default class CustomFormattingHelper {

private _formulaEvaluator: FormulaEvaluation;

/**
* Custom Formatting Helper / Renderer
* @param formulaEvaluator An instance of FormulaEvaluation used for evaluating expressions in custom formatting
*/
constructor(formulaEvaluator: FormulaEvaluation) {
this._formulaEvaluator = formulaEvaluator;
}

/**
* The Formula Evaluator expects an ASTNode to be passed to it for evaluation. This method converts expressions
* described by the interface ICustomFormattingExpressionNode to ASTNodes.
* @param node An ICustomFormattingExpressionNode to be converted to an ASTNode
*/
private convertCustomFormatExpressionNodes = (node: ICustomFormattingExpressionNode | string | number | boolean): ASTNode => {
if (typeof node !== "object") {
switch (typeof node) {
case "string":
return { type: "string", value: node };
case "number":
return { type: "number", value: node };
case "boolean":
return { type: "booelan", value: node ? 1 : 0 };
}
}
const operator = node.operator;
const operands = node.operands.map(o => this.convertCustomFormatExpressionNodes(o));
return { type: "operator", value: operator, operands };
}

/**
* Given a single custom formatting expression, node or element, this method evaluates the expression and returns the result
* @param content An object, expression or literal value to be evaluated
* @param context A context object containing values / variables to be used in the evaluation
* @returns
*/
private evaluateCustomFormatContent = (content: ICustomFormattingExpressionNode | ICustomFormattingNode | string | number | boolean, context: Context): CustomFormatResult => {

// If content is a string or number, it is a literal value and should be returned as-is
if ((typeof content === "string" && content.charAt(0) !== "=") || typeof content === "number") return content;

// If content is a string beginning with '=' it is a formula/expression, and should be evaluated
if (typeof content === "string" && content.charAt(0) === "=") {
const result = this._formulaEvaluator.evaluate(content.substring(1), context);
return result as CustomFormatResult;
}

// If content is an object, it is either further custom formatting described by an ICustomFormattingNode,
// or an expression to be evaluated - as described by an ICustomFormattingExpressionNode

if (typeof content === "object") {

if (Object.prototype.hasOwnProperty.call(content, "elmType")) {

// Custom Formatting Content
return this.renderCustomFormatContent(content as ICustomFormattingNode, context);

} else if (Object.prototype.hasOwnProperty.call(content, "operator")) {

// Expression to be evaluated
const astNode = this.convertCustomFormatExpressionNodes(content as ICustomFormattingExpressionNode);
const result = this._formulaEvaluator.evaluateASTNode(astNode, context);
if (typeof result === "object" && Object.prototype.hasOwnProperty.call(result, "elmType")) {
return this.renderCustomFormatContent(result as ICustomFormattingNode, context);
}
return result as CustomFormatResult;

}
}
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
public renderCustomFormatContent = (node: ICustomFormattingNode, context: Context, rootEl: boolean = false): JSX.Element | string | number => {

// We don't want attempts to render custom format content to kill the component or web part,
// so we wrap the entire method in a try/catch block, log errors and return null if an error occurs
try {

// If node is a string or number, it is a literal value and should be returned as-is
if (typeof node === "string" || typeof node === "number") return node;

// Custom formatting nodes / elements may have a txtContent property, which represents the inner
// content of a HTML element. This can be a string literal, or another expression to be evaluated:
let textContent: CustomFormatResult | undefined;
if (node.txtContent) {
textContent = this.evaluateCustomFormatContent(node.txtContent, context);
}

// Custom formatting nodes / elements may have a style property, which contains the style rules
// to be applied to the resulting HTML element. Rule values can be string literals or another expression
// to be evaluated:
const styleProperties: React.CSSProperties = {};
if (node.style) {
for (const styleAttribute in node.style) {
if (node.style[styleAttribute]) {
styleProperties[styleAttribute] = this.evaluateCustomFormatContent(node.style[styleAttribute], context) as string;
}
}
}

// Custom formatting nodes / elements may have an attributes property, which represents the HTML attributes
// to be applied to the resulting HTML element. Attribute values can be string literals or another expression
// to be evaluated:
const attributes = {} as Record<string, string>;
if (node.attributes) {
for (const attribute in node.attributes) {
if (node.attributes[attribute]) {
let attributeName = attribute;

// Because we're using React to render the HTML content, we need to rename the 'class' attribute
if (attributeName === "class") attributeName = "className";

// Evaluation
attributes[attributeName] = this.evaluateCustomFormatContent(node.attributes[attribute], context) as string;

// Add the 'sp-field-customFormatter' class to the root element
if (attributeName === "className" && rootEl) {
attributes[attributeName] = `${attributes[attributeName]} sp-field-customFormatter`;
}
}
}
}

// Custom formatting nodes / elements may have children. These are likely to be further custom formatting
let children: (CustomFormatResult)[] = [];

// If the node has an iconName property, we'll render an Icon component as the first child.
// SharePoint uses CSS to apply the icon in a ::before rule, but we can't count on the global selector for iconName
// being present on the page, so we'll add it as a child instead:
if (attributes.iconName) {
const icon = React.createElement(Icon, { iconName: attributes.iconName });
children.push(icon);
}

// Each child object is evaluated recursively and added to the children array
if (node.children) {
children = node.children.map(c => this.evaluateCustomFormatContent(c, context));
}

// The resulting HTML element is returned to the callee using React.createElement
const el = React.createElement(node.elmType, { style: styleProperties, ...attributes }, textContent, ...children);
return el;
} catch (error) {
console.error('Unable to render custom formatted content', error);
return null;
}
}
}
Loading

0 comments on commit 3cd6f18

Please sign in to comment.