Skip to content

Commit d9281fa

Browse files
author
Tom German
committed
Added form validation to Dynamic Form
1 parent d42db42 commit d9281fa

File tree

9 files changed

+822
-17
lines changed

9 files changed

+822
-17
lines changed

src/common/utilities/FormulaEvaluation.ts

Lines changed: 611 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
export type TokenType = "FUNCTION" | "STRING" | "NUMBER" | "UNARY_MINUS" | "BOOLEAN" | "WORD" | "OPERATOR" | "ARRAY" | "VARIABLE";
3+
4+
export class Token {
5+
type: TokenType;
6+
value: string | number;
7+
8+
constructor(tokenType: TokenType, value: string | number) {
9+
this.type = tokenType;
10+
this.value = value;
11+
}
12+
13+
toString(): string {
14+
return `${this.type}: ${this.value}`;
15+
}
16+
}
17+
18+
export class ArrayLiteralNode {
19+
elements: (string | number | ArrayLiteralNode)[];
20+
21+
constructor(elements: (string | number | ArrayLiteralNode)[]) {
22+
this.elements = elements; // Store array elements
23+
}
24+
25+
evaluate(): any {
26+
// Evaluate array elements and return the array
27+
const evaluatedElements: any = this.elements.map((element) => {
28+
if (element instanceof ArrayLiteralNode) {
29+
return element.evaluate();
30+
} else {
31+
if (
32+
typeof element === "string" &&
33+
(
34+
(element.startsWith("'") && element.endsWith("'")) ||
35+
(element.startsWith('"') && element.endsWith('"'))
36+
)
37+
) {
38+
return element.slice(1, -1);
39+
} else {
40+
return element;
41+
}
42+
}
43+
});
44+
return evaluatedElements;
45+
}
46+
}
47+
48+
export type ASTNode = {
49+
type: string;
50+
value?: string | number;
51+
operands?: (ASTNode | ArrayLiteralNode)[];
52+
};
53+
54+
export type Context = { [key: string]: any };
55+
56+
export const ValidFuncNames = [
57+
"if",
58+
"ternary",
59+
"Number",
60+
"abs",
61+
"floor",
62+
"ceiling",
63+
"pow",
64+
"cos",
65+
"sin",
66+
"indexOf",
67+
"lastIndexOf",
68+
"toString",
69+
"join",
70+
"substring",
71+
"toUpperCase",
72+
"toLowerCase",
73+
"startsWith",
74+
"endsWith",
75+
"replaceAll",
76+
"replace",
77+
"padStart",
78+
"padEnd",
79+
"split",
80+
"toDateString",
81+
"toDate",
82+
"toLocaleString",
83+
"toLocaleDateString",
84+
"toLocaleTimeString",
85+
"getDate",
86+
"getMonth",
87+
"getYear",
88+
"addDays",
89+
"addMinutes",
90+
"getUserImage",
91+
"getThumbnailImage",
92+
"indexOf",
93+
"length",
94+
"appendTo",
95+
"removeFrom",
96+
"loopIndex"
97+
];

src/controls/dynamicForm/DynamicForm.tsx

Lines changed: 102 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/* eslint-disable @microsoft/spfx/no-async-await */
22
import { SPHttpClient } from "@microsoft/sp-http";
3-
import { IRenderListDataAsStreamResult, sp } from "@pnp/sp/presets/all";
43
import * as strings from "ControlStrings";
54
import {
65
DefaultButton,
@@ -11,6 +10,7 @@ import { ProgressIndicator } from "office-ui-fabric-react/lib/ProgressIndicator"
1110
import { IStackTokens, Stack } from "office-ui-fabric-react/lib/Stack";
1211
import * as React from "react";
1312
import { ISPField, IUploadImageResult } from "../../common/SPEntities";
13+
import { FormulaEvaluation } from "../../common/utilities/FormulaEvaluation";
1414
import SPservice from "../../services/SPService";
1515
import { IFilePickerResult } from "../filePicker";
1616
import { DynamicField } from "./dynamicField";
@@ -32,6 +32,7 @@ import "@pnp/sp/lists";
3232
import "@pnp/sp/content-types";
3333
import "@pnp/sp/folders";
3434
import "@pnp/sp/items";
35+
import { sp } from "@pnp/sp";
3536

3637
const stackTokens: IStackTokens = { childrenGap: 20 };
3738

@@ -43,6 +44,7 @@ export class DynamicForm extends React.Component<
4344
IDynamicFormState
4445
> {
4546
private _spService: SPservice;
47+
private _formulaEvaluation: FormulaEvaluation;
4648
private webURL = this.props.webAbsoluteUrl
4749
? this.props.webAbsoluteUrl
4850
: this.props.context.pageContext.web.absoluteUrl;
@@ -71,12 +73,16 @@ export class DynamicForm extends React.Component<
7173
fieldCollection: [],
7274
validationFormulas: {},
7375
clientValidationFormulas: {},
76+
validationErrors: {},
77+
hiddenByFormula: [],
7478
isValidationErrorDialogOpen: false,
7579
};
7680
// Get SPService Factory
7781
this._spService = this.props.webAbsoluteUrl
7882
? new SPservice(this.props.context, this.props.webAbsoluteUrl)
7983
: new SPservice(this.props.context);
84+
// Formula Validation util
85+
this._formulaEvaluation = new FormulaEvaluation(this.props.context);
8086
}
8187

8288
/**
@@ -96,7 +102,7 @@ export class DynamicForm extends React.Component<
96102
* Default React component render method
97103
*/
98104
public render(): JSX.Element {
99-
const { fieldCollection, isSaving } = this.state;
105+
const { fieldCollection, hiddenByFormula, isSaving, validationErrors } = this.state;
100106

101107
const fieldOverrides = this.props.fieldOverrides;
102108

@@ -112,6 +118,13 @@ export class DynamicForm extends React.Component<
112118
) : (
113119
<div>
114120
{fieldCollection.map((v, i) => {
121+
if (hiddenByFormula.find(h => h === v.columnInternalName)) {
122+
return null;
123+
}
124+
let validationErrorMessage: string = "";
125+
if (validationErrors[v.columnInternalName]) {
126+
validationErrorMessage = validationErrors[v.columnInternalName];
127+
}
115128
if (
116129
fieldOverrides &&
117130
Object.prototype.hasOwnProperty.call(
@@ -127,6 +140,7 @@ export class DynamicForm extends React.Component<
127140
key={v.columnInternalName}
128141
{...v}
129142
disabled={v.disabled || isSaving}
143+
validationErrorMessage={validationErrorMessage}
130144
/>
131145
);
132146
})}
@@ -215,12 +229,17 @@ export class DynamicForm extends React.Component<
215229
}
216230
}
217231
});
232+
const validationErrors = await this.evaluateFormulas(this.state.validationFormulas, true) as Record<string,string>;
233+
if (Object.keys(validationErrors).length > 0) {
234+
shouldBeReturnBack = true;
235+
}
218236
if (shouldBeReturnBack) {
219237
this.setState({
220238
fieldCollection: fields,
221239
isValidationErrorDialogOpen:
222240
this.props.validationErrorDialogProps
223241
?.showDialogOnValidationError === true,
242+
validationErrors
224243
});
225244
return;
226245
}
@@ -416,11 +435,10 @@ export class DynamicForm extends React.Component<
416435
// trigger when the user change any value in the form
417436
private onChange = async (
418437
internalName: string,
438+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
419439
newValue: any,
420440
additionalData?: FieldChangeAdditionalData
421441
): Promise<void> => {
422-
// eslint-disable-line @typescript-eslint/no-explicit-any
423-
// try {
424442
const fieldCol = (this.state.fieldCollection || []).slice();
425443
const field = fieldCol.filter((element, i) => {
426444
return element.columnInternalName === internalName;
@@ -462,9 +480,85 @@ export class DynamicForm extends React.Component<
462480
}
463481
this.setState({
464482
fieldCollection: fieldCol,
465-
});
483+
}, this.performValidation);
466484
};
467485

486+
/** Validation callback, used when form first loads (getListInformation) and following onChange */
487+
private performValidation = async (skipFieldValueValidation?: boolean): Promise<void> => {
488+
const hiddenByFormula = await this.evaluateColumnVisibilityFormulas();
489+
let validationErrors = {...this.state.validationErrors};
490+
if (!skipFieldValueValidation) validationErrors = await this.evaluateFieldValueFormulas();
491+
this.setState({ hiddenByFormula, validationErrors });
492+
}
493+
494+
/** Determines visibility of fields that have show/hide formulas set in Edit Form > Edit Columns > Edit Conditional Formula */
495+
private evaluateColumnVisibilityFormulas = async(): Promise<string[]> => {
496+
return await this.evaluateFormulas(this.state.clientValidationFormulas, false) as string[];
497+
}
498+
499+
/** Evaluates field validation formulas set in column settings and returns a Record of error messages */
500+
private evaluateFieldValueFormulas = async(): Promise<Record<string,string>> => {
501+
return await this.evaluateFormulas(this.state.validationFormulas, true, true) as Record<string,string>;
502+
}
503+
504+
/**
505+
* Evaluates formulas and returns a Record of error messages or an array of column names that have failed validation
506+
* @param formulas A Record / dictionary-like object, where key is internal column name and value is an object with ValidationFormula and ValidationMessage properties
507+
* @param returnMessages Determines whether a Record of error messages is returned or an array of column names that have failed validation
508+
* @param requireValue Set to true if the formula should only be evaluated when the field has a value
509+
* @returns
510+
*/
511+
private evaluateFormulas = async(
512+
formulas: Record<string, Pick<ISPField, "ValidationFormula" | "ValidationMessage">>,
513+
returnMessages = true,
514+
requireValue: boolean = false
515+
): Promise<string[]|Record<string,string>> => {
516+
const { fieldCollection } = this.state;
517+
const results: Record<string, string> = {};
518+
for (let i = 0; i < Object.keys(formulas).length; i++) {
519+
const fieldName = Object.keys(formulas)[i];
520+
if (formulas[fieldName]) {
521+
const field = fieldCollection.find(f => f.columnInternalName === fieldName);
522+
if (!field) continue;
523+
const formula = formulas[fieldName].ValidationFormula;
524+
const message = formulas[fieldName].ValidationMessage;
525+
if (!formula) continue;
526+
const context = this.getFormValues();
527+
if (requireValue && !context[fieldName]) continue;
528+
const result = await this._formulaEvaluation.evaluate(formula, context);
529+
if (Boolean(result) !== true) {
530+
results[fieldName] = message;
531+
}
532+
}
533+
}
534+
if (!returnMessages) { return Object.keys(results); }
535+
return results;
536+
}
537+
538+
private getFormValues = (): Record<string,unknown> => {
539+
const { fieldCollection } = this.state;
540+
return fieldCollection.reduce((prev, cur) => {
541+
let value: unknown;
542+
switch(cur.fieldType) {
543+
case "Lookup":
544+
case "Choice":
545+
case "TaxonomyFieldType":
546+
value = cur.newValue?.key;
547+
break;
548+
case "LookupMulti":
549+
case "MultiChoice":
550+
case "TaxonomyFieldTypeMulti":
551+
value = cur.newValue?.map((v) => v.key);
552+
break;
553+
default:
554+
value = cur.newValue;
555+
break;
556+
}
557+
prev[cur.columnInternalName] = value;
558+
return prev;
559+
}, {} as Record<string, unknown>);
560+
}
561+
468562
private getListInformation = async(): Promise<void> => {
469563
const {
470564
listId,
@@ -718,17 +812,13 @@ export class DynamicForm extends React.Component<
718812
tempFields.sort((a, b) => a.Order - b.Order);
719813
}
720814
}
721-
722-
// Do formatting and validation parsing here
723-
console.log('Validation Formulas', validationFormulas);
724-
console.log('Client Side Validation Formulas', clientValidationFormulas);
725815

726816
this.setState({
727817
clientValidationFormulas,
728818
fieldCollection: tempFields,
729819
validationFormulas,
730820
etag
731-
});
821+
}, () => this.performValidation(true));
732822

733823
} catch (error) {
734824
console.log(`Error get field informations`, error);
@@ -1047,8 +1137,8 @@ export class DynamicForm extends React.Component<
10471137
listId: string,
10481138
contentTypeId: string | undefined,
10491139
webUrl?: string
1050-
): Promise<any> => {
1051-
// eslint-disable-line @typescript-eslint/no-explicit-any
1140+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1141+
): Promise<any> => {
10521142
try {
10531143
const { context } = this.props;
10541144
const webAbsoluteUrl = !webUrl ? this.webURL : webUrl;

src/controls/dynamicForm/IDynamicFormState.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@ import { ISPField } from '../../common/SPEntities';
33
import { IDynamicFieldProps } from './dynamicField/IDynamicFieldProps';
44
export interface IDynamicFormState {
55
fieldCollection: IDynamicFieldProps[];
6+
/** Validation Formulas set in List Column settings */
67
validationFormulas: Record<string, Pick<ISPField, 'ValidationFormula' | 'ValidationMessage'>>;
8+
/** Field Show / Hide Validation Formulas, set in Edit Form > Edit Columns > Edit Conditional Formula */
79
clientValidationFormulas: Record<string, Pick<ISPField, 'ValidationFormula' | 'ValidationMessage'>>;
10+
/** Tracks fields hidden by ClientValidationFormula */
11+
hiddenByFormula: string[];
12+
/** Populated by evaluation of List Column Setting validation. Key is internal field name, value is the configured error message. */
13+
validationErrors: Record<string, string>;
814
isSaving?: boolean;
915
etag?: string;
1016
isValidationErrorDialogOpen: boolean;

src/controls/dynamicForm/dynamicField/DynamicField.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export class DynamicField extends React.Component<IDynamicFieldProps, IDynamicFi
9494

9595

9696
const labelEl = <label className={(required) ? styles.fieldRequired + ' ' + styles.fieldLabel : styles.fieldLabel}>{labelText}</label>;
97-
const errorText = this.getRequiredErrorText();
97+
const errorText = this.props.validationErrorMessage || this.getRequiredErrorText();
9898
const errorTextEl = <text className={styles.errormessage}>{errorText}</text>;
9999
const descriptionEl = <text className={styles.fieldDescription}>{description}</text>;
100100
const hasImage = !!changedValue;
@@ -276,7 +276,7 @@ export class DynamicField extends React.Component<IDynamicFieldProps, IDynamicFi
276276
onChange={(e, newText) => { this.onChange(newText); }}
277277
disabled={disabled}
278278
onBlur={this.onBlur}
279-
errorMessage={customNumberErrorMessage} />
279+
errorMessage={errorText || customNumberErrorMessage} />
280280
{descriptionEl}
281281
</div>;
282282

src/controls/dynamicForm/dynamicField/IDynamicFieldProps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,5 @@ export interface IDynamicFieldProps {
3737
maximumValue?: number;
3838
minimumValue?: number;
3939
showAsPercentage?: boolean;
40+
validationErrorMessage?: string;
4041
}

src/controls/webPartTitle/WebPartTitle.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export class WebPartTitle extends React.Component<IWebPartTitleProps, {}> {
5454
<div className={styles.webPartTitle} style={{ color: color }}>
5555
{
5656
this.props.displayMode === DisplayMode.Edit && (
57-
<textarea placeholder={this.props.placeholder ? this.props.placeholder : strings.WebPartTitlePlaceholder} aria-label={strings.WebPartTitleLabel} onChange={this._onChange} defaultValue={this.props.title} />
57+
<textarea placeholder={this.props.placeholder ? this.props.placeholder : strings.WebPartTitlePlaceholder} aria-label={strings.WebPartTitleLabel} onChange={this._onChange} value={this.props.title} />
5858
)
5959
}
6060

src/webparts/controlsTest/ControlsTestWebPart.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export default class ControlsTestWebPart extends BaseClientSideWebPart<IControls
130130
groupName: strings.ControlSettingsGroupName,
131131
groupFields: [
132132
PropertyPaneTextField('title', {
133-
label: 'Web Part Title',
133+
label: 'Web Part Title'
134134
}),
135135
PropertyPaneTextField('paginationTotalPages', {
136136
label: 'Total pages in pagination'

src/webparts/controlsTest/propertyPane/PropertyPaneControlToggles.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
} from '@microsoft/sp-property-pane';
77
import { IPropertyPaneCustomFieldProps } from '@microsoft/sp-property-pane';
88
import { ControlToggles, IControlTogglesProps } from './controls/ControlToggles';
9-
import { ControlVisibility, ValidControls } from '../IControlsTestWebPartProps';
9+
import { ControlVisibility } from '../IControlsTestWebPartProps';
1010

1111
export interface IPropertyPaneControlTogglesProps {
1212
label: string;

0 commit comments

Comments
 (0)