@servicetitan/dte-pdf-editor 1.16.0 → 1.18.0

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.
Files changed (190) hide show
  1. package/README.md +35 -7
  2. package/dist/components/field-config-panel/advanced-settings.d.ts +9 -0
  3. package/dist/components/field-config-panel/advanced-settings.d.ts.map +1 -0
  4. package/dist/components/field-config-panel/advanced-settings.js +17 -0
  5. package/dist/components/field-config-panel/advanced-settings.js.map +1 -0
  6. package/dist/components/field-config-panel/field-config-panel-overlay.d.ts +4 -1
  7. package/dist/components/field-config-panel/field-config-panel-overlay.d.ts.map +1 -1
  8. package/dist/components/field-config-panel/field-config-panel-overlay.js +2 -2
  9. package/dist/components/field-config-panel/field-config-panel-overlay.js.map +1 -1
  10. package/dist/components/field-config-panel/field-config-panel.d.ts +4 -1
  11. package/dist/components/field-config-panel/field-config-panel.d.ts.map +1 -1
  12. package/dist/components/field-config-panel/field-config-panel.js +11 -5
  13. package/dist/components/field-config-panel/field-config-panel.js.map +1 -1
  14. package/dist/components/field-config-panel/field-sidebar.d.ts +13 -0
  15. package/dist/components/field-config-panel/field-sidebar.d.ts.map +1 -0
  16. package/dist/components/field-config-panel/field-sidebar.js +20 -0
  17. package/dist/components/field-config-panel/field-sidebar.js.map +1 -0
  18. package/dist/components/field-config-panel/formula-generator.d.ts +11 -0
  19. package/dist/components/field-config-panel/formula-generator.d.ts.map +1 -0
  20. package/dist/components/field-config-panel/formula-generator.js +51 -0
  21. package/dist/components/field-config-panel/formula-generator.js.map +1 -0
  22. package/dist/components/field-config-panel/formula-modal.d.ts +12 -0
  23. package/dist/components/field-config-panel/formula-modal.d.ts.map +1 -0
  24. package/dist/components/field-config-panel/formula-modal.js +99 -0
  25. package/dist/components/field-config-panel/formula-modal.js.map +1 -0
  26. package/dist/components/field-config-panel/formula-workspace.d.ts +23 -0
  27. package/dist/components/field-config-panel/formula-workspace.d.ts.map +1 -0
  28. package/dist/components/field-config-panel/formula-workspace.js +28 -0
  29. package/dist/components/field-config-panel/formula-workspace.js.map +1 -0
  30. package/dist/components/field-config-panel/result-type-selector.d.ts +9 -0
  31. package/dist/components/field-config-panel/result-type-selector.d.ts.map +1 -0
  32. package/dist/components/field-config-panel/result-type-selector.js +10 -0
  33. package/dist/components/field-config-panel/result-type-selector.js.map +1 -0
  34. package/dist/components/field-sidebar/calculated-field-type-list.d.ts +9 -0
  35. package/dist/components/field-sidebar/calculated-field-type-list.d.ts.map +1 -0
  36. package/dist/components/field-sidebar/calculated-field-type-list.js +12 -0
  37. package/dist/components/field-sidebar/calculated-field-type-list.js.map +1 -0
  38. package/dist/components/field-sidebar/data-model-field-type-list.d.ts +0 -1
  39. package/dist/components/field-sidebar/data-model-field-type-list.d.ts.map +1 -1
  40. package/dist/components/field-sidebar/data-model-field-type-list.js +8 -7
  41. package/dist/components/field-sidebar/data-model-field-type-list.js.map +1 -1
  42. package/dist/components/field-sidebar/field-menu-group.d.ts +11 -0
  43. package/dist/components/field-sidebar/field-menu-group.d.ts.map +1 -0
  44. package/dist/components/field-sidebar/field-menu-group.js +6 -0
  45. package/dist/components/field-sidebar/field-menu-group.js.map +1 -0
  46. package/dist/components/field-sidebar/field-sidebar.d.ts.map +1 -1
  47. package/dist/components/field-sidebar/field-sidebar.js +6 -15
  48. package/dist/components/field-sidebar/field-sidebar.js.map +1 -1
  49. package/dist/components/field-sidebar/fillable-field-type-list.d.ts +0 -1
  50. package/dist/components/field-sidebar/fillable-field-type-list.d.ts.map +1 -1
  51. package/dist/components/field-sidebar/fillable-field-type-list.js +8 -9
  52. package/dist/components/field-sidebar/fillable-field-type-list.js.map +1 -1
  53. package/dist/components/pdf-editor/pdf-editor.d.ts.map +1 -1
  54. package/dist/components/pdf-editor/pdf-editor.js +1 -1
  55. package/dist/components/pdf-editor/pdf-editor.js.map +1 -1
  56. package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.d.ts +8 -0
  57. package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.d.ts.map +1 -0
  58. package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.js +5 -0
  59. package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.js.map +1 -0
  60. package/dist/components/pdf-fields-overlay/pdf-overlay-field.d.ts.map +1 -1
  61. package/dist/components/pdf-fields-overlay/pdf-overlay-field.js +11 -6
  62. package/dist/components/pdf-fields-overlay/pdf-overlay-field.js.map +1 -1
  63. package/dist/components/pdf-view/pdf-view-calculated.d.ts +9 -0
  64. package/dist/components/pdf-view/pdf-view-calculated.d.ts.map +1 -0
  65. package/dist/components/pdf-view/pdf-view-calculated.js +18 -0
  66. package/dist/components/pdf-view/pdf-view-calculated.js.map +1 -0
  67. package/dist/components/pdf-view/pdf-view.d.ts.map +1 -1
  68. package/dist/components/pdf-view/pdf-view.js +2 -1
  69. package/dist/components/pdf-view/pdf-view.js.map +1 -1
  70. package/dist/constants/field.constants.d.ts +3 -2
  71. package/dist/constants/field.constants.d.ts.map +1 -1
  72. package/dist/constants/field.constants.js +6 -0
  73. package/dist/constants/field.constants.js.map +1 -1
  74. package/dist/constants/menu-group.d.ts +8 -0
  75. package/dist/constants/menu-group.d.ts.map +1 -0
  76. package/dist/constants/menu-group.js +20 -0
  77. package/dist/constants/menu-group.js.map +1 -0
  78. package/dist/hooks/index.d.ts +1 -0
  79. package/dist/hooks/index.d.ts.map +1 -1
  80. package/dist/hooks/index.js +1 -0
  81. package/dist/hooks/index.js.map +1 -1
  82. package/dist/hooks/useFieldDrag.d.ts +9 -2
  83. package/dist/hooks/useFieldDrag.d.ts.map +1 -1
  84. package/dist/hooks/useFieldDrag.js +60 -8
  85. package/dist/hooks/useFieldDrag.js.map +1 -1
  86. package/dist/hooks/useFormulaEditor.d.ts +22 -0
  87. package/dist/hooks/useFormulaEditor.d.ts.map +1 -0
  88. package/dist/hooks/useFormulaEditor.js +290 -0
  89. package/dist/hooks/useFormulaEditor.js.map +1 -0
  90. package/dist/hooks/usePdfFieldDnD.d.ts.map +1 -1
  91. package/dist/hooks/usePdfFieldDnD.js +19 -3
  92. package/dist/hooks/usePdfFieldDnD.js.map +1 -1
  93. package/dist/interface/types.d.ts +45 -3
  94. package/dist/interface/types.d.ts.map +1 -1
  95. package/dist/interface/types.js +3 -0
  96. package/dist/interface/types.js.map +1 -1
  97. package/dist/utils/data-model/extract-fields.utils.d.ts +5 -5
  98. package/dist/utils/data-model/extract-fields.utils.d.ts.map +1 -1
  99. package/dist/utils/data-model/extract-fields.utils.js +42 -8
  100. package/dist/utils/data-model/extract-fields.utils.js.map +1 -1
  101. package/dist/utils/formula/caret.utils.d.ts +3 -0
  102. package/dist/utils/formula/caret.utils.d.ts.map +1 -0
  103. package/dist/utils/formula/caret.utils.js +123 -0
  104. package/dist/utils/formula/caret.utils.js.map +1 -0
  105. package/dist/utils/formula/dom.utils.d.ts +4 -0
  106. package/dist/utils/formula/dom.utils.d.ts.map +1 -0
  107. package/dist/utils/formula/dom.utils.js +34 -0
  108. package/dist/utils/formula/dom.utils.js.map +1 -0
  109. package/dist/utils/formula/evaluate-formula.utils.d.ts +13 -0
  110. package/dist/utils/formula/evaluate-formula.utils.d.ts.map +1 -0
  111. package/dist/utils/formula/evaluate-formula.utils.js +134 -0
  112. package/dist/utils/formula/evaluate-formula.utils.js.map +1 -0
  113. package/dist/utils/formula/expression.utils.d.ts +18 -0
  114. package/dist/utils/formula/expression.utils.d.ts.map +1 -0
  115. package/dist/utils/formula/expression.utils.js +84 -0
  116. package/dist/utils/formula/expression.utils.js.map +1 -0
  117. package/dist/utils/formula/format-calculated-result.utils.d.ts +7 -0
  118. package/dist/utils/formula/format-calculated-result.utils.d.ts.map +1 -0
  119. package/dist/utils/formula/format-calculated-result.utils.js +50 -0
  120. package/dist/utils/formula/format-calculated-result.utils.js.map +1 -0
  121. package/dist/utils/formula/formula-types.d.ts +3 -0
  122. package/dist/utils/formula/formula-types.d.ts.map +1 -0
  123. package/dist/utils/formula/formula-types.js +2 -0
  124. package/dist/utils/formula/formula-types.js.map +1 -0
  125. package/dist/utils/formula/index.d.ts +11 -0
  126. package/dist/utils/formula/index.d.ts.map +1 -0
  127. package/dist/utils/formula/index.js +11 -0
  128. package/dist/utils/formula/index.js.map +1 -0
  129. package/dist/utils/formula/referenced-paths.utils.d.ts +7 -0
  130. package/dist/utils/formula/referenced-paths.utils.d.ts.map +1 -0
  131. package/dist/utils/formula/referenced-paths.utils.js +18 -0
  132. package/dist/utils/formula/referenced-paths.utils.js.map +1 -0
  133. package/dist/utils/formula/render-formula.utils.d.ts +8 -0
  134. package/dist/utils/formula/render-formula.utils.d.ts.map +1 -0
  135. package/dist/utils/formula/render-formula.utils.js +39 -0
  136. package/dist/utils/formula/render-formula.utils.js.map +1 -0
  137. package/dist/utils/formula/serialize-formula.utils.d.ts +14 -0
  138. package/dist/utils/formula/serialize-formula.utils.d.ts.map +1 -0
  139. package/dist/utils/formula/serialize-formula.utils.js +33 -0
  140. package/dist/utils/formula/serialize-formula.utils.js.map +1 -0
  141. package/dist/utils/formula/validate-formula.utils.d.ts +11 -0
  142. package/dist/utils/formula/validate-formula.utils.d.ts.map +1 -0
  143. package/dist/utils/formula/validate-formula.utils.js +79 -0
  144. package/dist/utils/formula/validate-formula.utils.js.map +1 -0
  145. package/dist/utils/index.d.ts +1 -0
  146. package/dist/utils/index.d.ts.map +1 -1
  147. package/dist/utils/index.js +1 -0
  148. package/dist/utils/index.js.map +1 -1
  149. package/package.json +2 -2
  150. package/src/components/field-config-panel/advanced-settings.tsx +113 -0
  151. package/src/components/field-config-panel/field-config-panel-overlay.tsx +8 -1
  152. package/src/components/field-config-panel/field-config-panel.tsx +43 -15
  153. package/src/components/field-config-panel/field-sidebar.tsx +91 -0
  154. package/src/components/field-config-panel/formula-generator.tsx +122 -0
  155. package/src/components/field-config-panel/formula-modal.tsx +229 -0
  156. package/src/components/field-config-panel/formula-workspace.tsx +116 -0
  157. package/src/components/field-config-panel/result-type-selector.tsx +34 -0
  158. package/src/components/field-sidebar/calculated-field-type-list.tsx +29 -0
  159. package/src/components/field-sidebar/data-model-field-type-list.tsx +10 -4
  160. package/src/components/field-sidebar/field-menu-group.tsx +36 -0
  161. package/src/components/field-sidebar/field-sidebar.tsx +14 -55
  162. package/src/components/field-sidebar/fillable-field-type-list.tsx +11 -9
  163. package/src/components/pdf-editor/pdf-editor.tsx +2 -0
  164. package/src/components/pdf-fields-overlay/pdf-overlay-field-calculated.tsx +15 -0
  165. package/src/components/pdf-fields-overlay/pdf-overlay-field.tsx +10 -5
  166. package/src/components/pdf-view/pdf-view-calculated.tsx +23 -0
  167. package/src/components/pdf-view/pdf-view.tsx +4 -0
  168. package/src/constants/field.constants.ts +9 -2
  169. package/src/constants/menu-group.ts +26 -0
  170. package/src/hooks/index.ts +1 -0
  171. package/src/hooks/useFieldDrag.ts +84 -8
  172. package/src/hooks/useFormulaEditor.ts +336 -0
  173. package/src/hooks/usePdfFieldDnD.ts +36 -14
  174. package/src/interface/types.ts +38 -2
  175. package/src/styles/formula-modal.css +307 -0
  176. package/src/styles/index.css +1 -0
  177. package/src/styles/pdf-field-overlay.css +1 -0
  178. package/src/utils/data-model/extract-fields.utils.ts +65 -7
  179. package/src/utils/formula/caret.utils.ts +125 -0
  180. package/src/utils/formula/dom.utils.ts +35 -0
  181. package/src/utils/formula/evaluate-formula.utils.ts +159 -0
  182. package/src/utils/formula/expression.utils.ts +99 -0
  183. package/src/utils/formula/format-calculated-result.utils.ts +79 -0
  184. package/src/utils/formula/formula-types.ts +2 -0
  185. package/src/utils/formula/index.ts +10 -0
  186. package/src/utils/formula/referenced-paths.utils.ts +18 -0
  187. package/src/utils/formula/render-formula.utils.ts +40 -0
  188. package/src/utils/formula/serialize-formula.utils.ts +40 -0
  189. package/src/utils/formula/validate-formula.utils.ts +94 -0
  190. package/src/utils/index.ts +1 -0
@@ -0,0 +1,159 @@
1
+ import {
2
+ DataModelValues,
3
+ FormulaOperator,
4
+ FormulaToken,
5
+ StructuredFormula,
6
+ } from '../../interface/types';
7
+ import { resolvePdfDataValues } from '../data-model';
8
+
9
+ /**
10
+ * Convert formula operand to number. Merge tags and fillable only ever give:
11
+ * empty '', a number, or a string number (e.g. '1.2'). Only those are converted to number for calculation.
12
+ */
13
+ export function valueToNumber(raw: unknown): number {
14
+ if (raw === null || raw === undefined) {
15
+ return 0;
16
+ }
17
+ if (typeof raw === 'number' && !Number.isNaN(raw)) {
18
+ return raw;
19
+ }
20
+ if (typeof raw !== 'string') {
21
+ return 0;
22
+ }
23
+ const s = raw.trim();
24
+ if (s === '') {
25
+ return 0;
26
+ }
27
+ const n = Number(s);
28
+ return Number.isNaN(n) ? 0 : n;
29
+ }
30
+
31
+ type ResolvedToken =
32
+ | { type: 'number'; value: number }
33
+ | { type: 'operator'; value: FormulaOperator }
34
+ | { type: 'lparen'; value: '(' }
35
+ | { type: 'rparen'; value: ')' };
36
+
37
+ function resolveTokens(
38
+ formula: StructuredFormula,
39
+ data: DataModelValues | undefined,
40
+ ): ResolvedToken[] {
41
+ return formula.tokens.map((t: FormulaToken): ResolvedToken => {
42
+ if (t.type === 'field') {
43
+ const raw = resolvePdfDataValues(data, t.path);
44
+ return { type: 'number', value: valueToNumber(raw) };
45
+ }
46
+ if (t.type === 'number') {
47
+ return { type: 'number', value: valueToNumber(t.value) };
48
+ }
49
+ return t as ResolvedToken;
50
+ });
51
+ }
52
+
53
+ function applyOp(a: number, op: FormulaOperator, b: number): number {
54
+ switch (op) {
55
+ case '+':
56
+ return a + b;
57
+ case '-':
58
+ return a - b;
59
+ case '*':
60
+ return a * b;
61
+ case '/':
62
+ return b === 0 ? 0 : a / b;
63
+ default:
64
+ return 0;
65
+ }
66
+ }
67
+
68
+ /** Find the index of the matching closing paren for an open paren at openIndex. */
69
+ function findMatchingParen(tokens: ResolvedToken[], openIndex: number): number {
70
+ let balance = 1;
71
+ for (let i = openIndex + 1; i < tokens.length; i++) {
72
+ const t = tokens[i];
73
+ if (t.type === 'lparen') {
74
+ balance++;
75
+ } else if (t.type === 'rparen') {
76
+ balance--;
77
+ if (balance === 0) {
78
+ return i;
79
+ }
80
+ }
81
+ }
82
+ return -1;
83
+ }
84
+
85
+ /** Evaluate a flat list of numbers and * / + - (no parentheses). */
86
+ function evalFlat(tokens: ResolvedToken[]): number {
87
+ const list = [...tokens];
88
+ // First pass: * and /
89
+ for (let i = 1; i < list.length - 1; ) {
90
+ const t = list[i];
91
+ if (t.type === 'operator' && (t.value === '*' || t.value === '/')) {
92
+ const a = (list[i - 1] as { type: 'number'; value: number }).value;
93
+ const b = (list[i + 1] as { type: 'number'; value: number }).value;
94
+ const result = applyOp(a, t.value, b);
95
+ list.splice(i - 1, 3, { type: 'number', value: result });
96
+ continue;
97
+ }
98
+ i += 2;
99
+ }
100
+ // Second pass: + and -
101
+ for (let i = 1; i < list.length - 1; ) {
102
+ const t = list[i];
103
+ if (t.type === 'operator' && (t.value === '+' || t.value === '-')) {
104
+ const a = (list[i - 1] as { type: 'number'; value: number }).value;
105
+ const b = (list[i + 1] as { type: 'number'; value: number }).value;
106
+ const result = applyOp(a, t.value, b);
107
+ list.splice(i - 1, 3, { type: 'number', value: result });
108
+ continue;
109
+ }
110
+ i += 2;
111
+ }
112
+ const single = list[0];
113
+ return single?.type === 'number' ? single.value : 0;
114
+ }
115
+
116
+ function evalResolvedTokens(tokens: ResolvedToken[]): number {
117
+ if (tokens.length === 0) {
118
+ return 0;
119
+ }
120
+ if (tokens.length === 1 && tokens[0].type === 'number') {
121
+ return tokens[0].value;
122
+ }
123
+
124
+ const list = [...tokens];
125
+
126
+ // Resolve innermost parentheses first
127
+ let idx = 0;
128
+ while (idx < list.length) {
129
+ if (list[idx].type === 'lparen') {
130
+ const close = findMatchingParen(list, idx);
131
+ if (close === -1) {
132
+ break;
133
+ }
134
+ const inner = list.slice(idx + 1, close);
135
+ const value = evalResolvedTokens(inner);
136
+ list.splice(idx, close - idx + 1, { type: 'number', value });
137
+ continue;
138
+ }
139
+ idx++;
140
+ }
141
+
142
+ return evalFlat(list);
143
+ }
144
+
145
+ /**
146
+ * Evaluate a structured formula with the given data.
147
+ * Field tokens are resolved via resolvePdfDataValues; string values are converted to number.
148
+ * Supports +, -, *, / and parentheses. Returns 0 when formula is empty or invalid.
149
+ */
150
+ export function evaluateFormula(
151
+ formula: StructuredFormula | undefined | null,
152
+ data: DataModelValues | undefined,
153
+ ): number {
154
+ if (!formula?.tokens?.length) {
155
+ return 0;
156
+ }
157
+ const resolved = resolveTokens(formula, data);
158
+ return evalResolvedTokens(resolved);
159
+ }
@@ -0,0 +1,99 @@
1
+ import { FormulaToken, StructuredFormula } from '../../interface/types';
2
+
3
+ /**
4
+ * Convert StructuredFormula tokens to an editable expression string.
5
+ * Uses field path as the identifier so parsing is unambiguous.
6
+ */
7
+ export function tokensToExpression(formula: StructuredFormula | undefined): string {
8
+ if (!formula?.tokens?.length) {
9
+ return '';
10
+ }
11
+ return formula.tokens
12
+ .map(t => {
13
+ if (t.type === 'field') {
14
+ return t.path;
15
+ }
16
+ return (t as { value: string }).value;
17
+ })
18
+ .join(' ')
19
+ .replace(/\s+/g, ' ')
20
+ .trim();
21
+ }
22
+
23
+ /** Token from tokenizing an expression string (before resolving to FormulaToken) */
24
+ export interface ExpressionPart {
25
+ type: 'field' | 'number' | 'operator' | 'paren' | 'text';
26
+ value: string;
27
+ }
28
+
29
+ // Field paths may contain hyphens (e.g. fillable_recipient_uuid with UUIDs)
30
+ const EXPRESSION_REGEX = /[A-Za-z_][A-Za-z0-9_.-]*|\d+(?:\.\d+)?|[()+\-*/]|\s+|./g;
31
+
32
+ export function tokenizeExpression(expression: string): ExpressionPart[] {
33
+ const tokens: ExpressionPart[] = [];
34
+ let match: RegExpExecArray | null;
35
+ while ((match = EXPRESSION_REGEX.exec(expression)) !== null) {
36
+ const value = match[0];
37
+ if (/^[A-Za-z_]/.test(value)) {
38
+ tokens.push({ type: 'field', value });
39
+ } else if (/^\d/.test(value)) {
40
+ tokens.push({ type: 'number', value });
41
+ } else if (/^[()+\-*/]$/.test(value)) {
42
+ tokens.push({ type: value === '(' || value === ')' ? 'paren' : 'operator', value });
43
+ } else {
44
+ tokens.push({ type: 'text', value });
45
+ }
46
+ }
47
+ return tokens;
48
+ }
49
+
50
+ /**
51
+ * Parse an expression string into FormulaToken[] using valid paths and path->label map.
52
+ * Invalid or unknown identifiers are skipped (or could be left as placeholder); only known paths become field tokens.
53
+ */
54
+ export function parseExpression(
55
+ expression: string,
56
+ validPaths: Set<string>,
57
+ pathToLabel: Map<string, string>,
58
+ ): FormulaToken[] {
59
+ const parts = tokenizeExpression(expression).filter(
60
+ p => p.type !== 'text' || p.value.trim() !== '',
61
+ );
62
+ const tokens: FormulaToken[] = [];
63
+
64
+ for (const part of parts) {
65
+ if (part.type === 'text') {
66
+ continue;
67
+ }
68
+ if (part.type === 'field') {
69
+ if (validPaths.has(part.value)) {
70
+ tokens.push({
71
+ type: 'field',
72
+ path: part.value,
73
+ label: pathToLabel.get(part.value) ?? part.value,
74
+ });
75
+ }
76
+ continue;
77
+ }
78
+ if (part.type === 'number') {
79
+ tokens.push({ type: 'number', value: part.value });
80
+ continue;
81
+ }
82
+ if (part.type === 'operator') {
83
+ tokens.push({
84
+ type: 'operator',
85
+ value: part.value as '+' | '-' | '*' | '/',
86
+ });
87
+ continue;
88
+ }
89
+ if (part.type === 'paren') {
90
+ tokens.push(
91
+ part.value === '('
92
+ ? { type: 'lparen', value: '(' }
93
+ : { type: 'rparen', value: ')' },
94
+ );
95
+ }
96
+ }
97
+
98
+ return tokens;
99
+ }
@@ -0,0 +1,79 @@
1
+ import { CalculatedFieldFormat } from '../../interface/types';
2
+
3
+ function roundValue(
4
+ value: number,
5
+ decimals: number,
6
+ mode: CalculatedFieldFormat['roundingMode'],
7
+ ): number {
8
+ const factor = 10 ** decimals;
9
+ const scaled = value * factor;
10
+ switch (mode) {
11
+ case 'floor':
12
+ return Math.floor(scaled) / factor;
13
+ case 'ceil':
14
+ return Math.ceil(scaled) / factor;
15
+ default:
16
+ return Math.round(scaled) / factor;
17
+ }
18
+ }
19
+
20
+ function formatIntegerPart(
21
+ s: string,
22
+ thousandsSeparator: boolean,
23
+ decimalSeparator: '.' | ',',
24
+ ): string {
25
+ if (!thousandsSeparator) {
26
+ return s;
27
+ }
28
+ const sep = decimalSeparator === ',' ? '.' : ','; // opposite for thousands
29
+ const parts: string[] = [];
30
+ let i = s.length;
31
+ while (i > 0) {
32
+ const start = Math.max(0, i - 3);
33
+ parts.unshift(s.slice(start, i));
34
+ i = start;
35
+ }
36
+ return parts.join(sep);
37
+ }
38
+
39
+ /**
40
+ * Format a calculated formula result for display using advanced settings.
41
+ * Applies rounding, decimal places, thousands/decimal separators, result type (number/currency/percent), and prefix/postfix.
42
+ */
43
+ export function formatCalculatedResult(
44
+ value: number,
45
+ format: CalculatedFieldFormat | undefined,
46
+ ): string {
47
+ if (format == null) {
48
+ return String(value);
49
+ }
50
+
51
+ const {
52
+ decimalSeparator,
53
+ decimalSeparatorEnabled,
54
+ decimals,
55
+ postfixText,
56
+ prefixText,
57
+ resultType,
58
+ roundingMode,
59
+ thousandsSeparator,
60
+ } = format;
61
+
62
+ let num = value;
63
+ if (resultType === 'percent') {
64
+ num = value * 100;
65
+ }
66
+
67
+ const rounded = roundValue(num, decimals, roundingMode);
68
+ const fixed = rounded.toFixed(decimals);
69
+ const [intPart, decPart] = fixed.split('.');
70
+
71
+ const decimalSep = decimalSeparatorEnabled ? decimalSeparator : '.';
72
+ const intFormatted = formatIntegerPart(intPart, thousandsSeparator, decimalSep);
73
+ const decSuffix = decimals > 0 ? decimalSep + (decPart ?? '') : '';
74
+
75
+ const numberStr = intFormatted + decSuffix;
76
+ const suffix = resultType === 'percent' ? '%' : '';
77
+
78
+ return `${prefixText}${numberStr}${suffix}${postfixText}`;
79
+ }
@@ -0,0 +1,2 @@
1
+ export type { FormulaOperator, FormulaToken, StructuredFormula } from '../../interface/types';
2
+ export { FORMULA_OPERATORS } from '../../interface/types';
@@ -0,0 +1,10 @@
1
+ export * from './caret.utils';
2
+ export * from './dom.utils';
3
+ export * from './evaluate-formula.utils';
4
+ export * from './expression.utils';
5
+ export * from './format-calculated-result.utils';
6
+ export * from './formula-types';
7
+ export * from './referenced-paths.utils';
8
+ export * from './render-formula.utils';
9
+ export * from './serialize-formula.utils';
10
+ export * from './validate-formula.utils';
@@ -0,0 +1,18 @@
1
+ import { StructuredFormula } from '../../interface/types';
2
+
3
+ /**
4
+ * Returns the set of data model field paths referenced in the formula.
5
+ * Used for recalculation scope: only these fields should trigger formula updates.
6
+ */
7
+ export function getReferencedPaths(formula: StructuredFormula | undefined | null): string[] {
8
+ if (!formula?.tokens?.length) {
9
+ return [];
10
+ }
11
+ const paths: string[] = [];
12
+ for (const token of formula.tokens) {
13
+ if (token.type === 'field') {
14
+ paths.push(token.path);
15
+ }
16
+ }
17
+ return paths;
18
+ }
@@ -0,0 +1,40 @@
1
+ import { FormulaToken } from '../../interface/types';
2
+ import { escapeHtml } from './dom.utils';
3
+ import { tokenizeExpression } from './expression.utils';
4
+
5
+ /**
6
+ * Renders formula tokens to HTML for the read-only preview (e.g. formula box).
7
+ * Uses the same token classes as the modal (dte-formula-token, dte-formula-token-field, etc.).
8
+ */
9
+ export function renderFormulaPreviewHtml(tokens: FormulaToken[]): string {
10
+ if (!tokens?.length) {
11
+ return '';
12
+ }
13
+ return tokens
14
+ .map(token => {
15
+ const type = token.type;
16
+ const text = type === 'field' ? token.label : (token as { value: string }).value;
17
+ const escaped = escapeHtml(text).replace(/ /g, '&nbsp;');
18
+ return `<span class="dte-formula-token dte-formula-token-${type}">${escaped}</span>`;
19
+ })
20
+ .join('');
21
+ }
22
+
23
+ export function renderFormulaHtml(expression: string, labelMap: Map<string, string>): string {
24
+ if (!expression.trim()) {
25
+ return '';
26
+ }
27
+ const parts = tokenizeExpression(expression);
28
+ let fieldIndex = 0;
29
+ return parts
30
+ .map(part => {
31
+ if (part.type === 'field') {
32
+ const displayValue = labelMap.get(part.value) ?? part.value;
33
+ const currentIndex = fieldIndex;
34
+ fieldIndex += 1;
35
+ return `<span class="dte-formula-chip" contenteditable="false" data-field="${escapeHtml(part.value)}" data-field-index="${currentIndex}" draggable="true"><span class="dte-formula-chip-label">${escapeHtml(displayValue)}</span><span class="dte-formula-chip-remove" data-field-remove="true" role="button" aria-label="Remove field">&times;</span></span>`;
36
+ }
37
+ return `<span>${escapeHtml(part.value).replace(/ /g, '&nbsp;')}</span>`;
38
+ })
39
+ .join('');
40
+ }
@@ -0,0 +1,40 @@
1
+ import { StructuredFormula } from '../../interface/types';
2
+
3
+ const SERIALIZATION_VERSION = 1;
4
+
5
+ export interface SerializedFormula {
6
+ v: number;
7
+ tokens: StructuredFormula['tokens'];
8
+ }
9
+
10
+ /**
11
+ * Serialize structured formula for persistence (e.g. to JSON in store).
12
+ */
13
+ export function serializeFormula(formula: StructuredFormula): string {
14
+ const payload: SerializedFormula = {
15
+ v: SERIALIZATION_VERSION,
16
+ tokens: formula.tokens,
17
+ };
18
+ return JSON.stringify(payload);
19
+ }
20
+
21
+ /**
22
+ * Deserialize formula from persisted string. Returns null if invalid.
23
+ */
24
+ export function deserializeFormula(json: string): StructuredFormula | null {
25
+ if (!json || typeof json !== 'string') {
26
+ return null;
27
+ }
28
+ try {
29
+ const payload = JSON.parse(json) as SerializedFormula;
30
+ if (payload?.v !== SERIALIZATION_VERSION || !Array.isArray(payload.tokens)) {
31
+ return null;
32
+ }
33
+ if (payload.tokens.length === 0) {
34
+ return { tokens: [] };
35
+ }
36
+ return { tokens: payload.tokens };
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
@@ -0,0 +1,94 @@
1
+ import {
2
+ FormulaToken,
3
+ FORMULA_OPERATORS,
4
+ StructuredFormula,
5
+ } from '../../interface/types';
6
+
7
+ export interface FormulaValidationResult {
8
+ valid: boolean;
9
+ errors: string[];
10
+ }
11
+
12
+ const OPERATOR_SET = new Set<string>(FORMULA_OPERATORS);
13
+
14
+ function isOperatorToken(t: FormulaToken): t is FormulaToken & { type: 'operator' } {
15
+ return t.type === 'operator';
16
+ }
17
+
18
+ function isOperandToken(t: FormulaToken): boolean {
19
+ return t.type === 'number' || t.type === 'field';
20
+ }
21
+
22
+ /**
23
+ * Validates a structured formula.
24
+ * Rules: not empty, valid operator placement, balanced parentheses, supported operators only, all field paths exist.
25
+ */
26
+ export function validateFormula(
27
+ formula: StructuredFormula | undefined | null,
28
+ validPaths: Set<string>,
29
+ ): FormulaValidationResult {
30
+ const errors: string[] = [];
31
+
32
+ if (!formula?.tokens?.length) {
33
+ return { valid: false, errors: ['Formula cannot be empty'] };
34
+ }
35
+
36
+ const tokens = formula.tokens;
37
+ let parenBalance = 0;
38
+
39
+ for (let i = 0; i < tokens.length; i++) {
40
+ const t = tokens[i];
41
+ const prev = tokens[i - 1];
42
+ const next = tokens[i + 1];
43
+
44
+ if (t.type === 'lparen') {
45
+ parenBalance++;
46
+ if (prev != null && isOperandToken(prev)) {
47
+ errors.push('Operator required before opening parenthesis');
48
+ }
49
+ } else if (t.type === 'rparen') {
50
+ parenBalance--;
51
+ if (parenBalance < 0) {
52
+ errors.push('Unbalanced parentheses');
53
+ }
54
+ if (prev != null && isOperatorToken(prev)) {
55
+ errors.push('Operand required before closing parenthesis');
56
+ }
57
+ } else if (t.type === 'operator') {
58
+ if (prev == null || next == null) {
59
+ errors.push('Operator must be between two operands or expressions');
60
+ } else if (prev.type === 'operator' || prev.type === 'lparen') {
61
+ errors.push('Operand required before operator');
62
+ } else if (next.type === 'operator' || next.type === 'rparen') {
63
+ errors.push('Operand required after operator');
64
+ }
65
+ } else if (t.type === 'field') {
66
+ if (!validPaths.has(t.path)) {
67
+ errors.push(`Unknown field: "${t.label}" (path: ${t.path})`);
68
+ }
69
+ } else if (t.type === 'number') {
70
+ const num = parseFloat(t.value);
71
+ if (Number.isNaN(num)) {
72
+ errors.push(`Invalid number: "${t.value}"`);
73
+ }
74
+ }
75
+ }
76
+
77
+ if (parenBalance !== 0) {
78
+ errors.push('Unbalanced parentheses');
79
+ }
80
+
81
+ const first = tokens[0];
82
+ const last = tokens[tokens.length - 1];
83
+ if (first && (first.type === 'operator' || first.type === 'rparen')) {
84
+ errors.push('Formula must start with an operand or opening parenthesis');
85
+ }
86
+ if (last && (last.type === 'operator' || last.type === 'lparen')) {
87
+ errors.push('Formula must end with an operand or closing parenthesis');
88
+ }
89
+
90
+ return {
91
+ valid: errors.length === 0,
92
+ errors,
93
+ };
94
+ }
@@ -1,5 +1,6 @@
1
1
  export * from './field';
2
2
  export * from './pdf';
3
3
  export * from './data-model';
4
+ export * from './formula';
4
5
  export * from './path';
5
6
  export * from './recipients';