Skip to content

Commit a9577b7

Browse files
author
Tom German
committed
Added tests and fixes.
1 parent d5b0f42 commit a9577b7

15 files changed

+584
-100
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/utilities/CustomFormatting.ts

Lines changed: 42 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -57,48 +57,53 @@ export default class CustomFormattingHelper {
5757

5858
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5959
public renderCustomFormatContent = (node: ICustomFormattingNode, context: any, rootEl: boolean = false): any => {
60-
if (typeof node === "string" || typeof node === "number") return node;
61-
// txtContent
62-
let textContent: JSX.Element | string | undefined;
63-
if (node.txtContent) {
64-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
65-
textContent = this.evaluateCustomFormatContent(node.txtContent, context) as any;
66-
}
67-
// style
68-
const styleProperties = {} as React.CSSProperties;
69-
if (node.style) {
70-
for (const styleAttribute in node.style) {
71-
if (node.style[styleAttribute]) {
72-
styleProperties[styleAttribute] = this.evaluateCustomFormatContent(node.style[styleAttribute], context) as string;
60+
try {
61+
if (typeof node === "string" || typeof node === "number") return node;
62+
// txtContent
63+
let textContent: JSX.Element | string | undefined;
64+
if (node.txtContent) {
65+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
66+
textContent = this.evaluateCustomFormatContent(node.txtContent, context) as any;
67+
}
68+
// style
69+
const styleProperties = {} as React.CSSProperties;
70+
if (node.style) {
71+
for (const styleAttribute in node.style) {
72+
if (node.style[styleAttribute]) {
73+
styleProperties[styleAttribute] = this.evaluateCustomFormatContent(node.style[styleAttribute], context) as string;
74+
}
7375
}
7476
}
75-
}
76-
// attributes
77-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
78-
const attributes = {} as any;
79-
if (node.attributes) {
80-
for (const attribute in node.attributes) {
81-
if (node.attributes[attribute]) {
82-
let attributeName = attribute;
83-
if (attributeName === "class") attributeName = "className";
84-
attributes[attributeName] = this.evaluateCustomFormatContent(node.attributes[attribute], context) as string;
85-
if (attributeName === "className" && rootEl) {
86-
attributes[attributeName] = `${attributes[attributeName]} sp-field-customFormatter`;
77+
// attributes
78+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
79+
const attributes = {} as any;
80+
if (node.attributes) {
81+
for (const attribute in node.attributes) {
82+
if (node.attributes[attribute]) {
83+
let attributeName = attribute;
84+
if (attributeName === "class") attributeName = "className";
85+
attributes[attributeName] = this.evaluateCustomFormatContent(node.attributes[attribute], context) as string;
86+
if (attributeName === "className" && rootEl) {
87+
attributes[attributeName] = `${attributes[attributeName]} sp-field-customFormatter`;
88+
}
8789
}
8890
}
8991
}
92+
// children
93+
let children: (JSX.Element | string | number | boolean | undefined)[] = [];
94+
if (attributes.iconName) {
95+
const icon = React.createElement(Icon, { iconName: attributes.iconName });
96+
children.push(icon);
97+
}
98+
if (node.children) {
99+
children = node.children.map(c => this.evaluateCustomFormatContent(c, context));
100+
}
101+
// render
102+
const el = React.createElement(node.elmType, { style: styleProperties, ...attributes }, textContent, ...children);
103+
return el;
104+
} catch (error) {
105+
console.error('Unable to render custom formatted content', error);
106+
return null;
90107
}
91-
// children
92-
let children: (JSX.Element | string | number | boolean | undefined)[] = [];
93-
if (attributes.iconName) {
94-
const icon = React.createElement(Icon, { iconName: attributes.iconName });
95-
children.push(icon);
96-
}
97-
if (node.children) {
98-
children = node.children.map(c => this.evaluateCustomFormatContent(c, context));
99-
}
100-
// render
101-
const el = React.createElement(node.elmType, { style: styleProperties, ...attributes }, textContent, ...children);
102-
return el;
103108
}
104109
}

src/common/utilities/FormulaEvaluation.ts

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,28 @@ import { ASTNode, ArrayLiteralNode, Token, TokenType, ValidFuncNames } from "./F
55

66
export class FormulaEvaluation {
77
private webUrl: string;
8-
constructor(context: IContext, webUrlOverride?: string) {
9-
sp.setup({ pageContext: context.pageContext });
10-
this.webUrl = webUrlOverride || context.pageContext.web.absoluteUrl;
8+
private _meEmail: string;
9+
10+
constructor(context?: IContext, webUrlOverride?: string) {
11+
if (context) {
12+
sp.setup({ pageContext: context.pageContext });
13+
this._meEmail = context.pageContext.user.email;
14+
}
15+
this.webUrl = webUrlOverride || context?.pageContext.web.absoluteUrl || '';
1116
}
1217

1318
/** Evaluates a formula expression and returns the result, with optional context object for variables */
1419
public evaluate(expression: string, context: { [key: string]: any } = {}): any {
15-
const tokens: Token[] = this._tokenize(expression, context);
16-
const postfix: Token[] = this._shuntingYard(tokens);
17-
const ast: ASTNode = this._buildAST(postfix);
20+
context['me'] = this._meEmail;
21+
context['today'] = new Date();
22+
const tokens: Token[] = this.tokenize(expression, context);
23+
const postfix: Token[] = this.shuntingYard(tokens);
24+
const ast: ASTNode = this.buildAST(postfix);
1825
return this.evaluateASTNode(ast, context);
1926
}
2027

2128
/** Tokenizes an expression into a list of tokens (primatives, operators, variables, function names, arrays etc) */
22-
private _tokenize(expression: string, context: { [key: string]: any }): Token[] {
29+
public tokenize(expression: string, context: { [key: string]: any } = {}): Token[] {
2330
// Each pattern captures a different token type
2431
// and are matched in order
2532
const patterns: [RegExp, TokenType][] = [
@@ -89,7 +96,7 @@ export class FormulaEvaluation {
8996
return tokens;
9097
}
9198

92-
private _shuntingYard(tokens: Token[]): Token[] {
99+
public shuntingYard(tokens: Token[]): Token[] {
93100

94101
/** Returns a precedence value for a token or operator */
95102
function getPrecedence(op: string): { precedence: number, associativity: "left" | "right" } {
@@ -234,7 +241,7 @@ export class FormulaEvaluation {
234241
}
235242
}
236243

237-
private _buildAST(postfixTokens: Token[]): ASTNode {
244+
public buildAST(postfixTokens: Token[]): ASTNode {
238245

239246
// Tokens are arranged on a stack/array of node objects
240247
const stack: (Token | ASTNode | ArrayLiteralNode)[] = [];
@@ -287,7 +294,7 @@ export class FormulaEvaluation {
287294
return stack[0] as ASTNode;
288295
}
289296

290-
public evaluateASTNode(node: ASTNode | ArrayLiteralNode | string | number, context: { [key: string]: any }): any {
297+
public evaluateASTNode(node: ASTNode | ArrayLiteralNode | string | number, context: { [key: string]: any } = {}): any {
291298

292299
if (!node) return 0;
293300

@@ -430,17 +437,17 @@ export class FormulaEvaluation {
430437
return arrayToJoin.join(separator);
431438
}
432439
case 'substring': {
433-
const mainStrSubstring = funcArgs[0];
434-
const start = funcArgs[1];
435-
const end = funcArgs[2];
440+
const mainStrSubstring = funcArgs[0] || '';
441+
const start = funcArgs[1] || 0;
442+
const end = funcArgs[2] || mainStrSubstring.length;
436443
return mainStrSubstring.substr(start, end);
437444
}
438445
case 'toUpperCase': {
439-
const strToUpper = funcArgs[0];
446+
const strToUpper = funcArgs[0] || '';
440447
return strToUpper.toUpperCase();
441448
}
442449
case 'toLowerCase': {
443-
const strToLower = funcArgs[0];
450+
const strToLower = funcArgs[0] || '';
444451
return strToLower.toLowerCase();
445452
}
446453
case 'startsWith': {
@@ -600,14 +607,32 @@ export class FormulaEvaluation {
600607

601608
return 0; // Default fallback
602609
}
610+
611+
public validate(expression: string): boolean {
612+
const validFunctionRegex = `(${ValidFuncNames.map(fn => `${fn}\\(`).join('|')})`;
613+
const pattern = new RegExp(`^(?:@\\w+|\\[\\$?[\\w+.]\\]|\\d+(?:\\.\\d+)?|"(?:[^"]*)"|'(?:[^']*)'|${validFunctionRegex}|[+\\-*/<>=%!&|?:,()\\[\\]]|\\?|:)`);
614+
615+
/* Explanation -
616+
/@\\w+/ matches variables specified by the form @variableName.
617+
/\\[\\$?\\w+\\/] matches variables specified by the forms [variableName] and [$variableName].
618+
/\\d+(?:\\.\\d+)?/ matches numbers, including decimal numbers.
619+
/"(?:[^"]*)"/ and /'(?:[^']*)'/ match string literals in double and single quotes, respectively.
620+
/${validFunctionRegex}/ matches valid function names.
621+
/\\?/ matches the ternary operator ?.
622+
/:/ matches the colon :.
623+
/[+\\-*///<>=%!&|?:,()\\[\\]]/ matches operators.
624+
625+
return pattern.test(expression);
626+
}
627+
603628
private _getSharePointThumbnailUrl(imageUrl: string): string {
604629
const filename = imageUrl.split('/').pop();
605630
const url = imageUrl.replace(filename, '');
606631
const [filenameNoExt, ext] = filename.split('.');
607-
return `${url}/_t/${filenameNoExt}_${ext}.jpg`;
632+
return `${url}_t/${filenameNoExt}_${ext}.jpg`;
608633
}
609634
private _getUserImageUrl(userEmail: string): string {
610-
return `${this.webUrl}/_layouts/15/userphoto.aspx?size=L&username=${userEmail}`
635+
return `${this.webUrl}/_layouts/15/userphoto.aspx?size=L&accountname=${userEmail}`
611636
}
612637
}
613638

0 commit comments

Comments
 (0)