@syncfusion/ej2-react-form-renderer 0.1.1-beta → 0.1.2-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * ExpressionParser - Parse and compile expressions
3
+ *
4
+ * Converts string expressions into safe, compiled functions
5
+ * Supports field references, operators, functions, and conditionals
6
+ *
7
+ * @spec Expression_Engine.md
8
+ */
9
+ export class ExpressionParser {
10
+ supportedFunctions = [
11
+ 'SUM', 'AVG', 'MIN', 'MAX', 'COUNT',
12
+ 'ROUND', 'FLOOR', 'CEIL', 'ABS', 'SQRT',
13
+ 'UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'TRIM', 'SUBSTRING',
14
+ 'TODAY', 'AGE', 'DATEFORMAT', 'DATEDIFF', 'ADDDAYS', 'SUBDAYS'
15
+ ];
16
+ availableFields = new Map();
17
+ /**
18
+ * Initialize parser with available fields for type validation
19
+ */
20
+ setAvailableFields(fields) {
21
+ this.availableFields.clear();
22
+ fields.forEach(field => {
23
+ this.availableFields.set(field.name, field);
24
+ });
25
+ }
26
+ validateFieldOperations(expression) {
27
+ const errors = [];
28
+ // Skip if no fields available
29
+ if (this.availableFields.size === 0) {
30
+ return errors;
31
+ }
32
+ // Invalid operators for string fields (allow only + for concatenation)
33
+ const stringInvalidOps = ['-', '*', '/', '%', '^'];
34
+ // All operators invalid for date fields
35
+ const dateInvalidOps = ['-', '*', '/', '%', '^', '+'];
36
+ // Track reported operator positions to avoid duplicates
37
+ const reportedOperators = new Set();
38
+ // Extract all field references and their surrounding operators
39
+ const fieldRefPattern = /(\{[^}]+\})/g;
40
+ let match;
41
+ while ((match = fieldRefPattern.exec(expression)) !== null) {
42
+ const fieldRef = match[0];
43
+ const fieldName = fieldRef.slice(1, -1); // Remove { }
44
+ const field = this.availableFields.get(fieldName);
45
+ if (!field)
46
+ continue; // Field not found in available fields, skip validation
47
+ const fieldIndex = match.index;
48
+ const fieldEndIndex = fieldIndex + fieldRef.length;
49
+ // Get context around field reference (including spaces)
50
+ const textBefore = expression.substring(Math.max(0, fieldIndex - 10), fieldIndex);
51
+ const textAfter = expression.substring(fieldEndIndex, Math.min(expression.length, fieldEndIndex + 10));
52
+ // Extract the operator if present (skip spaces)
53
+ const operatorBefore = textBefore.trimEnd().slice(-1);
54
+ const operatorAfter = textAfter.trimStart().charAt(0);
55
+ // Calculate actual position of operators in the original expression
56
+ const operatorBeforePos = fieldIndex - (textBefore.length - textBefore.trimEnd().length) - 1;
57
+ const operatorAfterPos = fieldEndIndex + (textAfter.length - textAfter.trimStart().length);
58
+ // For STRING fields: reject all operators except +
59
+ if (field.type === 'string' && !field.format) {
60
+ if (operatorBefore && stringInvalidOps.includes(operatorBefore)) {
61
+ const key = `${operatorBeforePos}:${operatorBefore}`;
62
+ if (!reportedOperators.has(key)) {
63
+ errors.push(`Cannot perform '${operatorBefore}' operation on string field '{${fieldName}}'`);
64
+ reportedOperators.add(key);
65
+ }
66
+ }
67
+ if (operatorAfter && stringInvalidOps.includes(operatorAfter)) {
68
+ const key = `${operatorAfterPos}:${operatorAfter}`;
69
+ if (!reportedOperators.has(key)) {
70
+ errors.push(`Cannot perform '${operatorAfter}' operation on string field '{${fieldName}}'`);
71
+ reportedOperators.add(key);
72
+ }
73
+ }
74
+ }
75
+ // For DATE fields: reject ALL operators including +
76
+ if (field.format === 'date' || field.format === 'date-time' || field.format === 'time') {
77
+ if (operatorBefore && dateInvalidOps.includes(operatorBefore)) {
78
+ const key = `${operatorBeforePos}:${operatorBefore}`;
79
+ if (!reportedOperators.has(key)) {
80
+ errors.push(`Cannot perform '${operatorBefore}' operation on date field '{${fieldName}}'`);
81
+ reportedOperators.add(key);
82
+ }
83
+ }
84
+ if (operatorAfter && dateInvalidOps.includes(operatorAfter)) {
85
+ const key = `${operatorAfterPos}:${operatorAfter}`;
86
+ if (!reportedOperators.has(key)) {
87
+ errors.push(`Cannot perform '${operatorAfter}' operation on date field '{${fieldName}}'`);
88
+ reportedOperators.add(key);
89
+ }
90
+ }
91
+ }
92
+ }
93
+ return errors;
94
+ }
95
+ /**
96
+ * Validate expression syntax
97
+ */
98
+ validate(expression) {
99
+ const errors = [];
100
+ if (!expression || expression.trim() === '') {
101
+ return { valid: true, errors: [] };
102
+ }
103
+ // Check for unbalanced braces
104
+ const openBraces = (expression.match(/\{/g) || []).length;
105
+ const closeBraces = (expression.match(/\}/g) || []).length;
106
+ if (openBraces !== closeBraces) {
107
+ errors.push('Unbalanced braces in expression');
108
+ }
109
+ // Check for unbalanced parentheses
110
+ const openParens = (expression.match(/\(/g) || []).length;
111
+ const closeParens = (expression.match(/\)/g) || []).length;
112
+ if (openParens !== closeParens) {
113
+ errors.push('Unbalanced parentheses in expression');
114
+ }
115
+ // Check for invalid function calls
116
+ const funcPattern = /(\w+)\s*\(/g;
117
+ let match;
118
+ while ((match = funcPattern.exec(expression)) !== null) {
119
+ const funcName = match[1].toUpperCase();
120
+ // Only validate uppercase-named functions
121
+ if (funcName === funcName.toUpperCase() && !this.supportedFunctions.includes(funcName)) {
122
+ errors.push(`Unknown function: ${funcName}`);
123
+ }
124
+ }
125
+ // Validate field operations based on field types
126
+ const fieldOpErrors = this.validateFieldOperations(expression);
127
+ errors.push(...fieldOpErrors);
128
+ return {
129
+ valid: errors.length === 0,
130
+ errors
131
+ };
132
+ }
133
+ /**
134
+ * Compile expression to executable function
135
+ */
136
+ compile(expression, options = {}) {
137
+ try {
138
+ // Set available fields for validation if provided
139
+ if (options.availableFields && Array.isArray(options.availableFields)) {
140
+ this.setAvailableFields(options.availableFields);
141
+ }
142
+ // Validate expression before compiling
143
+ const validation = this.validate(expression);
144
+ if (!validation.valid) {
145
+ console.error('Expression validation errors:', validation.errors, { expression });
146
+ return () => null;
147
+ }
148
+ // Replace field references with context lookups
149
+ let compiled = expression;
150
+ // Handle field references FIRST (this includes refs inside ${...})
151
+ compiled = this.compileFieldReferences(compiled);
152
+ // Handle bare function calls SECOND (e.g., SUM([...]) without braces)
153
+ compiled = this.compileBareFunctionCalls(compiled);
154
+ // Handle math expressions THIRD (now ${} and {} contain already-replaced refs)
155
+ compiled = this.compileMatrixExpressions(compiled);
156
+ // Create function that safely evaluates the expression
157
+ return (context) => {
158
+ try {
159
+ // This uses Function constructor with controlled context binding
160
+ // which is safer than eval() but still allows expressions
161
+ // Note: compiled expression already uses context['field'] syntax
162
+ const func = new Function('context', `
163
+ try {
164
+ return ${compiled};
165
+ } catch (e) {
166
+ return null;
167
+ }
168
+ `);
169
+ return func(context);
170
+ }
171
+ catch (e) {
172
+ console.error('Expression execution error:', e, { expression, compiled });
173
+ return null;
174
+ }
175
+ };
176
+ }
177
+ catch (error) {
178
+ console.error('Expression compilation error:', error, { expression });
179
+ return () => null;
180
+ }
181
+ }
182
+ // Private helpers
183
+ compileMatrixExpressions(expression) {
184
+ let compiled = expression;
185
+ const keywords = ['and', 'or', 'in', 'new', 'typeof', 'instanceof', 'delete', 'void', 'this', 'super', 'async', 'await'];
186
+ const constants = ['PI', 'TRUE', 'FALSE', 'NULL', 'UNDEFINED', 'Math'];
187
+ const functions = [
188
+ 'SUM', 'AVG', 'MIN', 'MAX', 'COUNT',
189
+ 'ROUND', 'FLOOR', 'CEIL', 'ABS', 'SQRT',
190
+ 'UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'TRIM', 'SUBSTRING',
191
+ 'TODAY', 'AGE', 'DATEFORMAT', 'DATEDIFF', 'ADDDAYS', 'SUBDAYS'
192
+ ];
193
+ // Create fresh regex each time to avoid lastIndex issues with g flag
194
+ const mathExprPattern = /\$?\{([^}]+)\}/g;
195
+ // Process ${...} and {...} blocks: replace bare identifiers with context lookups
196
+ compiled = compiled.replace(mathExprPattern, (_match, content) => {
197
+ let transformed = content;
198
+ // STEP 1: Handle function calls first
199
+ // Match function names followed by ( and prefix with context.
200
+ // Use negative lookbehind to avoid double-replacing (e.g., context.SUM)
201
+ functions.forEach(fn => {
202
+ const funcCallRegex = new RegExp(`(?<!context\\.)\\b${fn}\\b(?=\\s*\\()`, 'g');
203
+ transformed = transformed.replace(funcCallRegex, `context.${fn}`);
204
+ });
205
+ constants.forEach(constant => {
206
+ const constantRegex = new RegExp(`\\b${constant}\\b`, 'g');
207
+ transformed = transformed.replace(constantRegex, `context.${constant}`);
208
+ });
209
+ // STEP 2: Replace other bare identifiers with context lookups
210
+ // Skip: operators, keywords, constants, and property/method access
211
+ // First, protect string literals by temporarily replacing them
212
+ const strings = [];
213
+ let stringProtected = transformed;
214
+ stringProtected = stringProtected.replace(/(['"`])(?:(?=(\\?))\2.)*?\1/g, (match) => {
215
+ strings.push(match);
216
+ return `__STRING_${strings.length - 1}__`;
217
+ });
218
+ // Now replace identifiers, but they won't match inside strings
219
+ stringProtected = stringProtected.replace(/(?<!context\[')\b([a-zA-Z_][a-zA-Z0-9_]*)\b(?!\s*[(\.])/g, (word) => {
220
+ // Skip keywords and constants
221
+ if (keywords.includes(word.toLowerCase()) || constants.includes(word.toUpperCase())) {
222
+ return word;
223
+ }
224
+ // Skip function names (already handled in step 1)
225
+ if (functions.includes(word.toUpperCase())) {
226
+ return word;
227
+ }
228
+ // Replace identifier with context access (but only if not already context['...'])
229
+ if (!word.startsWith('context')) {
230
+ return `context['${word}']`;
231
+ }
232
+ return word;
233
+ });
234
+ // Return as parenthesized expression
235
+ return `(${transformed})`;
236
+ });
237
+ return compiled;
238
+ }
239
+ compileBareFunctionCalls(expression) {
240
+ let compiled = expression;
241
+ const functions = [
242
+ 'SUM', 'AVG', 'MIN', 'MAX', 'COUNT',
243
+ 'ROUND', 'FLOOR', 'CEIL', 'ABS', 'SQRT',
244
+ 'UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'TRIM', 'SUBSTRING',
245
+ 'TODAY', 'AGE', 'DATEFORMAT', 'DATEDIFF', 'ADDDAYS', 'SUBDAYS'
246
+ ];
247
+ // Handle bare function calls like SUM([...]) that are not inside ${...} or {...}
248
+ // Use negative lookbehind to avoid matching if already preceded by context.
249
+ functions.forEach(fn => {
250
+ const funcCallRegex = new RegExp(`(?<!context\\.)\\b${fn}\\b(?=\\s*\\()`, 'g');
251
+ compiled = compiled.replace(funcCallRegex, `context.${fn}`);
252
+ });
253
+ return compiled;
254
+ }
255
+ compileFieldReferences(expression) {
256
+ let compiled = expression;
257
+ // Create fresh regex each time to avoid lastIndex issues with g flag
258
+ const fieldRefPattern = /\{([^{}]+)\}/g;
259
+ // Replace {fieldName} with context['fieldName'] or field access
260
+ compiled = compiled.replace(fieldRefPattern, (match, ref) => {
261
+ // Handle escaped braces
262
+ if (ref.startsWith('\\') && ref.endsWith('\\')) {
263
+ return match; // Keep as is
264
+ }
265
+ // Handle nested paths like {customer.name}
266
+ return `context['${ref.replace(/\./g, '\'].[')}']`;
267
+ });
268
+ return compiled;
269
+ }
270
+ }
@@ -1,13 +1,19 @@
1
1
  import type { FormSchema, FormComponent } from '../FormBuilder/types/FormSchema';
2
2
  export declare const generateId: (type: string) => string;
3
3
  export declare const generateName: (type: string) => string;
4
- export interface SplitSchema {
5
- dataSchema: DataSchema;
6
- uiSchema: UISchema;
4
+ export interface UnifiedSchema {
5
+ version: string;
6
+ properties: Record<string, UnifiedField>;
7
+ theme?: string;
8
+ layout: LayoutNode[];
9
+ settings?: any;
10
+ }
11
+ export interface UnifiedField extends DataField, UIField {
7
12
  }
8
13
  export interface DataSchema {
9
14
  version: string;
10
15
  properties: Record<string, DataField>;
16
+ settings?: any;
11
17
  }
12
18
  export interface DataField {
13
19
  id: string;
@@ -16,6 +22,7 @@ export interface DataField {
16
22
  label?: string;
17
23
  format?: 'date' | 'date-time' | 'time' | 'email' | 'url';
18
24
  defaultValue?: any;
25
+ expressionValue?: string;
19
26
  content?: string;
20
27
  buttonType?: string;
21
28
  textboxType?: string;
@@ -40,6 +47,10 @@ export interface UIField {
40
47
  _configured?: boolean;
41
48
  [key: string]: any;
42
49
  }
50
+ export interface SplitSchema {
51
+ dataSchema: DataSchema;
52
+ uiSchema: UISchema;
53
+ }
43
54
  export type LayoutNode = FieldNode | TableNode | PanelNode | TabsNode | CardNode;
44
55
  export interface FieldNode {
45
56
  type: 'field';
@@ -118,12 +129,16 @@ export declare function labelToFieldKey(label: string, fallbackId?: string): str
118
129
  * Ensure field key is unique by appending a number if needed
119
130
  */
120
131
  export declare function makeUniqueFieldKey(baseKey: string, existingKeys: Set<string>, originalId?: string): string;
121
- export declare function splitSchema(heartSchema: FormSchema): SplitSchema;
132
+ export declare const UI_TYPE_TO_SEMANTIC_TYPE: Record<string, {
133
+ type: DataField['type'];
134
+ format?: string;
135
+ } | null>;
136
+ export declare function splitSchema(heartSchema: FormSchema): UnifiedSchema;
122
137
  /**
123
- * Merge split schemas back into heart schema
124
- * Supports both simple merge and fuzzy matching for re-import scenarios
138
+ * Merge unified schema back into heart schema
139
+ * Reconstructs FormComponent array from unified properties and layout
125
140
  */
126
- export declare function mergeSchema(split: SplitSchema, existingUISchema?: UISchema): FormSchema;
141
+ export declare function mergeSchema(unified: UnifiedSchema): FormSchema;
127
142
  /**
128
143
  * Infer widget type from semantic data type
129
144
  */
@@ -149,32 +164,8 @@ export declare function mergeWithIDRemapping(newDataSchema: DataSchema, existing
149
164
  * Extract all field IDs from layout tree
150
165
  */
151
166
  export declare function extractFieldIdsFromLayout(layout: LayoutNode[]): Set<string>;
152
- /**
153
- * Find unmatched fields (in data schema but not in layout)
154
- */
155
- export declare function findUnmatchedFields(dataSchema: DataSchema, layout: LayoutNode[]): string[];
156
167
  /**
157
168
  * Convert parser output (FormComponent[]) to DataSchema format
158
169
  * This allows parser results to be merged with existing UI schemas
159
170
  */
160
171
  export declare function convertComponentsToDataSchema(components: FormComponent[]): DataSchema;
161
- /**
162
- * Convert parser output to a simple linear layout
163
- * Used when no existing UI schema is present
164
- */
165
- export declare function convertComponentsToUISchema(components: FormComponent[]): UISchema;
166
- /**
167
- * Generate default UI properties for each data field
168
- * Used by renderers when only data schema is provided
169
- */
170
- export declare function generateUIPropertiesFromData(dataSchema: DataSchema): Record<string, UIField>;
171
- /**
172
- * Generate sequential layout from data schema
173
- * Used by renderers when only data schema is provided
174
- */
175
- export declare function generateLayoutFromData(dataSchema: DataSchema): FieldNode[];
176
- /**
177
- * Generate minimal data schema from UI schema
178
- * Used by renderers when only UI schema is provided
179
- */
180
- export declare function generateDataSchemaFromUI(uiSchema: UISchema): DataSchema;