@servicetitan/dte-pdf-editor 1.34.0 → 1.36.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.
- package/README.md +28 -12
- package/dist/components/display-conditions/condition-row.d.ts.map +1 -1
- package/dist/components/display-conditions/condition-row.js +1 -1
- package/dist/components/display-conditions/condition-row.js.map +1 -1
- package/dist/components/field-config-panel/advanced-settings.d.ts.map +1 -1
- package/dist/components/field-config-panel/advanced-settings.js +20 -10
- package/dist/components/field-config-panel/advanced-settings.js.map +1 -1
- package/dist/components/field-config-panel/formula-generator.d.ts.map +1 -1
- package/dist/components/field-config-panel/formula-generator.js +2 -1
- package/dist/components/field-config-panel/formula-generator.js.map +1 -1
- package/dist/components/field-config-panel/formula-modal.d.ts.map +1 -1
- package/dist/components/field-config-panel/formula-modal.js +47 -6
- package/dist/components/field-config-panel/formula-modal.js.map +1 -1
- package/dist/components/field-config-panel/formula-workspace.d.ts +1 -0
- package/dist/components/field-config-panel/formula-workspace.d.ts.map +1 -1
- package/dist/components/field-config-panel/formula-workspace.js +10 -4
- package/dist/components/field-config-panel/formula-workspace.js.map +1 -1
- package/dist/components/field-config-panel/result-type-selector.d.ts.map +1 -1
- package/dist/components/field-config-panel/result-type-selector.js +1 -0
- package/dist/components/field-config-panel/result-type-selector.js.map +1 -1
- package/dist/components/pdf-view/pdf-view-calculated.d.ts +1 -0
- package/dist/components/pdf-view/pdf-view-calculated.d.ts.map +1 -1
- package/dist/components/pdf-view/pdf-view-calculated.js +20 -3
- package/dist/components/pdf-view/pdf-view-calculated.js.map +1 -1
- package/dist/components/pdf-view/pdf-view-fillable.d.ts.map +1 -1
- package/dist/components/pdf-view/pdf-view-fillable.js +5 -2
- package/dist/components/pdf-view/pdf-view-fillable.js.map +1 -1
- package/dist/components/pdf-view/pdf-view.d.ts +6 -0
- package/dist/components/pdf-view/pdf-view.d.ts.map +1 -1
- package/dist/components/pdf-view/pdf-view.js +2 -2
- package/dist/components/pdf-view/pdf-view.js.map +1 -1
- package/dist/constants/calculated.constants.d.ts +2 -0
- package/dist/constants/calculated.constants.d.ts.map +1 -1
- package/dist/constants/calculated.constants.js +2 -0
- package/dist/constants/calculated.constants.js.map +1 -1
- package/dist/hooks/useFormulaEditor.d.ts +3 -0
- package/dist/hooks/useFormulaEditor.d.ts.map +1 -1
- package/dist/hooks/useFormulaEditor.js +43 -35
- package/dist/hooks/useFormulaEditor.js.map +1 -1
- package/dist/interface/types.d.ts +7 -1
- package/dist/interface/types.d.ts.map +1 -1
- package/dist/interface/types.js.map +1 -1
- package/dist/utils/conditions/schema-data-points.utils.d.ts.map +1 -1
- package/dist/utils/conditions/schema-data-points.utils.js +4 -1
- package/dist/utils/conditions/schema-data-points.utils.js.map +1 -1
- package/dist/utils/data-model/extract-fields.utils.d.ts.map +1 -1
- package/dist/utils/data-model/extract-fields.utils.js +11 -1
- package/dist/utils/data-model/extract-fields.utils.js.map +1 -1
- package/dist/utils/formula/caret.utils.js +4 -4
- package/dist/utils/formula/caret.utils.js.map +1 -1
- package/dist/utils/formula/dom.utils.js +2 -2
- package/dist/utils/formula/dom.utils.js.map +1 -1
- package/dist/utils/formula/evaluate-formula.utils.d.ts +10 -2
- package/dist/utils/formula/evaluate-formula.utils.d.ts.map +1 -1
- package/dist/utils/formula/evaluate-formula.utils.js +205 -4
- package/dist/utils/formula/evaluate-formula.utils.js.map +1 -1
- package/dist/utils/formula/expression.utils.d.ts +10 -9
- package/dist/utils/formula/expression.utils.d.ts.map +1 -1
- package/dist/utils/formula/expression.utils.js +39 -41
- package/dist/utils/formula/expression.utils.js.map +1 -1
- package/dist/utils/formula/format-calculated-result.utils.d.ts +1 -1
- package/dist/utils/formula/format-calculated-result.utils.d.ts.map +1 -1
- package/dist/utils/formula/format-calculated-result.utils.js +29 -8
- package/dist/utils/formula/format-calculated-result.utils.js.map +1 -1
- package/dist/utils/formula/index.d.ts +0 -2
- package/dist/utils/formula/index.d.ts.map +1 -1
- package/dist/utils/formula/index.js +0 -2
- package/dist/utils/formula/index.js.map +1 -1
- package/dist/utils/formula/validate-formula.utils.d.ts +1 -1
- package/dist/utils/formula/validate-formula.utils.d.ts.map +1 -1
- package/dist/utils/formula/validate-formula.utils.js +15 -1
- package/dist/utils/formula/validate-formula.utils.js.map +1 -1
- package/dist/utils/shared/date.utils.d.ts +2 -0
- package/dist/utils/shared/date.utils.d.ts.map +1 -0
- package/dist/utils/shared/date.utils.js +6 -0
- package/dist/utils/shared/date.utils.js.map +1 -0
- package/dist/utils/shared/index.d.ts +1 -0
- package/dist/utils/shared/index.d.ts.map +1 -1
- package/dist/utils/shared/index.js +1 -0
- package/dist/utils/shared/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/display-conditions/condition-row.tsx +1 -0
- package/src/components/field-config-panel/advanced-settings.tsx +103 -77
- package/src/components/field-config-panel/formula-generator.tsx +2 -1
- package/src/components/field-config-panel/formula-modal.tsx +46 -6
- package/src/components/field-config-panel/formula-workspace.tsx +21 -7
- package/src/components/field-config-panel/result-type-selector.tsx +1 -0
- package/src/components/pdf-view/pdf-view-calculated.tsx +20 -3
- package/src/components/pdf-view/pdf-view-fillable.tsx +6 -3
- package/src/components/pdf-view/pdf-view.tsx +8 -0
- package/src/constants/calculated.constants.ts +4 -0
- package/src/hooks/useFormulaEditor.ts +108 -97
- package/src/interface/types.ts +8 -1
- package/src/utils/conditions/schema-data-points.utils.ts +4 -1
- package/src/utils/data-model/extract-fields.utils.ts +13 -1
- package/src/utils/formula/caret.utils.ts +1 -1
- package/src/utils/formula/dom.utils.ts +1 -1
- package/src/utils/formula/evaluate-formula.utils.ts +271 -5
- package/src/utils/formula/expression.utils.ts +44 -47
- package/src/utils/formula/format-calculated-result.utils.ts +32 -10
- package/src/utils/formula/index.ts +0 -2
- package/src/utils/formula/validate-formula.utils.ts +24 -0
- package/src/utils/shared/date.utils.ts +6 -0
- package/src/utils/shared/index.ts +1 -0
- package/dist/utils/formula/referenced-paths.utils.d.ts +0 -7
- package/dist/utils/formula/referenced-paths.utils.d.ts.map +0 -1
- package/dist/utils/formula/referenced-paths.utils.js +0 -18
- package/dist/utils/formula/referenced-paths.utils.js.map +0 -1
- package/dist/utils/formula/serialize-formula.utils.d.ts +0 -14
- package/dist/utils/formula/serialize-formula.utils.d.ts.map +0 -1
- package/dist/utils/formula/serialize-formula.utils.js +0 -33
- package/dist/utils/formula/serialize-formula.utils.js.map +0 -1
- package/src/utils/formula/referenced-paths.utils.ts +0 -18
- package/src/utils/formula/serialize-formula.utils.ts +0 -40
|
@@ -28,6 +28,26 @@ export function valueToNumber(raw: unknown): number {
|
|
|
28
28
|
return Number.isNaN(n) ? 0 : n;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function hasAllFieldValues(
|
|
32
|
+
formula: StructuredFormula,
|
|
33
|
+
data: DataModelValues | undefined,
|
|
34
|
+
dateFieldPaths?: Set<string>,
|
|
35
|
+
): boolean {
|
|
36
|
+
return formula.tokens.every((t: FormulaToken) => {
|
|
37
|
+
if (t.type !== 'field') {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
const raw = resolvePdfDataValues(data, t.path);
|
|
41
|
+
if (raw === '') {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (dateFieldPaths?.has(t.path)) {
|
|
45
|
+
return !isNaN(new Date(raw).getTime());
|
|
46
|
+
}
|
|
47
|
+
return !isNaN(Number(raw.trim()));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
type ResolvedToken =
|
|
32
52
|
| { type: 'number'; value: number }
|
|
33
53
|
| { type: 'operator'; value: FormulaOperator }
|
|
@@ -66,7 +86,7 @@ function applyOp(a: number, op: FormulaOperator, b: number): number {
|
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
/** Find the index of the matching closing paren for an open paren at openIndex. */
|
|
69
|
-
function findMatchingParen(tokens:
|
|
89
|
+
function findMatchingParen<T extends { type: string }>(tokens: T[], openIndex: number): number {
|
|
70
90
|
let balance = 1;
|
|
71
91
|
for (let i = openIndex + 1; i < tokens.length; i++) {
|
|
72
92
|
const t = tokens[i];
|
|
@@ -123,7 +143,6 @@ function evalResolvedTokens(tokens: ResolvedToken[]): number {
|
|
|
123
143
|
|
|
124
144
|
const list = [...tokens];
|
|
125
145
|
|
|
126
|
-
// Resolve innermost parentheses first
|
|
127
146
|
let idx = 0;
|
|
128
147
|
while (idx < list.length) {
|
|
129
148
|
if (list[idx].type === 'lparen') {
|
|
@@ -142,18 +161,265 @@ function evalResolvedTokens(tokens: ResolvedToken[]): number {
|
|
|
142
161
|
return evalFlat(list);
|
|
143
162
|
}
|
|
144
163
|
|
|
164
|
+
/*
|
|
165
|
+
* ---------------------------------------------------------------------------
|
|
166
|
+
* Date-aware evaluation (business-day logic: skips weekends & holidays)
|
|
167
|
+
* ---------------------------------------------------------------------------
|
|
168
|
+
*/
|
|
169
|
+
|
|
170
|
+
type ValueKind = 'number' | 'date';
|
|
171
|
+
|
|
172
|
+
interface TypedValue {
|
|
173
|
+
kind: ValueKind;
|
|
174
|
+
value: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
type DateAwareToken =
|
|
178
|
+
| TypedValue
|
|
179
|
+
| { type: 'operator'; value: FormulaOperator }
|
|
180
|
+
| { type: 'lparen'; value: '(' }
|
|
181
|
+
| { type: 'rparen'; value: ')' };
|
|
182
|
+
|
|
183
|
+
function isTypedValue(t: DateAwareToken): t is TypedValue {
|
|
184
|
+
return 'kind' in t;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseDateValue(raw: string): number {
|
|
188
|
+
const d = new Date(raw);
|
|
189
|
+
return isNaN(d.getTime()) ? NaN : d.getTime();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function toDateOnly(ms: number): Date {
|
|
193
|
+
const d = new Date(ms);
|
|
194
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
195
|
+
return d;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function toDateKey(d: Date): string {
|
|
199
|
+
const y = d.getUTCFullYear();
|
|
200
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
201
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
202
|
+
return `${y}-${m}-${day}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isWeekend(d: Date): boolean {
|
|
206
|
+
const day = d.getUTCDay();
|
|
207
|
+
return day === 0 || day === 6;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function buildHolidaySet(holidays?: string[]): Set<string> {
|
|
211
|
+
if (!holidays?.length) {
|
|
212
|
+
return new Set();
|
|
213
|
+
}
|
|
214
|
+
const set = new Set<string>();
|
|
215
|
+
for (const h of holidays) {
|
|
216
|
+
const d = toDateOnly(new Date(h).getTime());
|
|
217
|
+
if (!isNaN(d.getTime())) {
|
|
218
|
+
set.add(toDateKey(d));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return set;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isNonBusinessDay(d: Date, holidaySet: Set<string>): boolean {
|
|
225
|
+
return isWeekend(d) || (holidaySet.size > 0 && holidaySet.has(toDateKey(d)));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Add business days to a date (epoch ms), skipping weekends and holidays.
|
|
230
|
+
* Returns the resulting date as epoch ms.
|
|
231
|
+
*/
|
|
232
|
+
function addBusinessDays(startMs: number, days: number, holidaySet: Set<string>): number {
|
|
233
|
+
const result = toDateOnly(startMs);
|
|
234
|
+
if (!isFinite(result.getTime())) {
|
|
235
|
+
return result.getTime();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const intDays = Math.trunc(days);
|
|
239
|
+
if (intDays === 0) {
|
|
240
|
+
return result.getTime();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const direction = intDays > 0 ? 1 : -1;
|
|
244
|
+
let remaining = Math.abs(intDays);
|
|
245
|
+
|
|
246
|
+
while (remaining > 0) {
|
|
247
|
+
result.setUTCDate(result.getUTCDate() + direction);
|
|
248
|
+
if (!isNonBusinessDay(result, holidaySet)) {
|
|
249
|
+
remaining--;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return result.getTime();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Count business days between two dates (epoch ms), skipping weekends and holidays.
|
|
258
|
+
* Positive when `toMs` is after `fromMs`, negative otherwise.
|
|
259
|
+
*/
|
|
260
|
+
function businessDayDiff(fromMs: number, toMs: number, holidaySet: Set<string>): number {
|
|
261
|
+
const start = toDateOnly(fromMs);
|
|
262
|
+
const end = toDateOnly(toMs);
|
|
263
|
+
|
|
264
|
+
if (!isFinite(start.getTime()) || !isFinite(end.getTime())) {
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const direction = end.getTime() >= start.getTime() ? 1 : -1;
|
|
269
|
+
const cursor = new Date(start.getTime());
|
|
270
|
+
let count = 0;
|
|
271
|
+
|
|
272
|
+
while (toDateKey(cursor) !== toDateKey(end)) {
|
|
273
|
+
cursor.setUTCDate(cursor.getUTCDate() + direction);
|
|
274
|
+
if (!isNonBusinessDay(cursor, holidaySet)) {
|
|
275
|
+
count++;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return count * direction;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function resolveDateAwareTokens(
|
|
283
|
+
formula: StructuredFormula,
|
|
284
|
+
data: DataModelValues | undefined,
|
|
285
|
+
dateFieldPaths: Set<string>,
|
|
286
|
+
): DateAwareToken[] {
|
|
287
|
+
return formula.tokens.map((t: FormulaToken): DateAwareToken => {
|
|
288
|
+
if (t.type === 'field') {
|
|
289
|
+
const raw = resolvePdfDataValues(data, t.path);
|
|
290
|
+
if (dateFieldPaths.has(t.path)) {
|
|
291
|
+
const ms = parseDateValue(raw);
|
|
292
|
+
return { kind: 'date', value: isNaN(ms) ? 0 : ms };
|
|
293
|
+
}
|
|
294
|
+
return { kind: 'number', value: valueToNumber(raw) };
|
|
295
|
+
}
|
|
296
|
+
if (t.type === 'number') {
|
|
297
|
+
return { kind: 'number', value: valueToNumber(t.value) };
|
|
298
|
+
}
|
|
299
|
+
return t as DateAwareToken;
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Apply an operator to two typed values with date-aware, business-day semantics:
|
|
305
|
+
* date + number → add business days → date
|
|
306
|
+
* number + date → add business days → date
|
|
307
|
+
* date - number → sub business days → date
|
|
308
|
+
* date - date → business day diff → number
|
|
309
|
+
* number ± number → normal → number
|
|
310
|
+
*/
|
|
311
|
+
function applyDateOp(
|
|
312
|
+
a: TypedValue,
|
|
313
|
+
op: FormulaOperator,
|
|
314
|
+
b: TypedValue,
|
|
315
|
+
holidaySet: Set<string>,
|
|
316
|
+
): TypedValue {
|
|
317
|
+
if (a.kind === 'date' && b.kind === 'number') {
|
|
318
|
+
if (op === '+') {
|
|
319
|
+
return { kind: 'date', value: addBusinessDays(a.value, b.value, holidaySet) };
|
|
320
|
+
}
|
|
321
|
+
if (op === '-') {
|
|
322
|
+
return { kind: 'date', value: addBusinessDays(a.value, -b.value, holidaySet) };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (a.kind === 'number' && b.kind === 'date' && op === '+') {
|
|
326
|
+
return { kind: 'date', value: addBusinessDays(b.value, a.value, holidaySet) };
|
|
327
|
+
}
|
|
328
|
+
if (a.kind === 'date' && b.kind === 'date' && op === '-') {
|
|
329
|
+
return { kind: 'number', value: businessDayDiff(a.value, b.value, holidaySet) };
|
|
330
|
+
}
|
|
331
|
+
return { kind: 'number', value: applyOp(a.value, op, b.value) };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function evalDateFlat(tokens: DateAwareToken[], holidaySet: Set<string>): TypedValue {
|
|
335
|
+
const list = [...tokens];
|
|
336
|
+
|
|
337
|
+
for (let i = 1; i < list.length - 1; ) {
|
|
338
|
+
const t = list[i];
|
|
339
|
+
if ('type' in t && t.type === 'operator' && (t.value === '+' || t.value === '-')) {
|
|
340
|
+
const left = list[i - 1];
|
|
341
|
+
const right = list[i + 1];
|
|
342
|
+
if (isTypedValue(left) && isTypedValue(right)) {
|
|
343
|
+
const result = applyDateOp(left, t.value, right, holidaySet);
|
|
344
|
+
list.splice(i - 1, 3, result);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
i += 2;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const single = list[0];
|
|
352
|
+
return isTypedValue(single) ? single : { kind: 'number', value: 0 };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function evalDateAwareTokens(tokens: DateAwareToken[], holidaySet: Set<string>): TypedValue {
|
|
356
|
+
if (tokens.length === 0) {
|
|
357
|
+
return { kind: 'number', value: 0 };
|
|
358
|
+
}
|
|
359
|
+
if (tokens.length === 1 && isTypedValue(tokens[0])) {
|
|
360
|
+
return tokens[0];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const list = [...tokens];
|
|
364
|
+
|
|
365
|
+
let idx = 0;
|
|
366
|
+
while (idx < list.length) {
|
|
367
|
+
if ('type' in list[idx] && (list[idx] as { type: string }).type === 'lparen') {
|
|
368
|
+
const close = findMatchingParen(list as { type: string }[], idx);
|
|
369
|
+
if (close === -1) {
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
const inner = list.slice(idx + 1, close);
|
|
373
|
+
const result = evalDateAwareTokens(inner as DateAwareToken[], holidaySet);
|
|
374
|
+
list.splice(idx, close - idx + 1, result);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
idx++;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return evalDateFlat(list as DateAwareToken[], holidaySet);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/*
|
|
384
|
+
* ---------------------------------------------------------------------------
|
|
385
|
+
* Public API
|
|
386
|
+
* ---------------------------------------------------------------------------
|
|
387
|
+
*/
|
|
388
|
+
|
|
145
389
|
/**
|
|
146
390
|
* Evaluate a structured formula with the given data.
|
|
147
391
|
* Field tokens are resolved via resolvePdfDataValues; string values are converted to number.
|
|
148
|
-
* Supports +, -, *, / and parentheses.
|
|
392
|
+
* Supports +, -, *, / and parentheses.
|
|
393
|
+
*
|
|
394
|
+
* Returns `null` when the formula is empty **or** when any field token resolves
|
|
395
|
+
* to null / undefined / empty / non-numeric (non-date) — making the entire
|
|
396
|
+
* calculation blank instead of silently substituting 0.
|
|
397
|
+
*
|
|
398
|
+
* When `dateFieldPaths` is provided, date fields are resolved to epoch-ms and
|
|
399
|
+
* arithmetic follows business-day semantics — weekends and holidays are skipped
|
|
400
|
+
* when adding/subtracting days, and date diffs count only business days.
|
|
149
401
|
*/
|
|
150
402
|
export function evaluateFormula(
|
|
151
403
|
formula: StructuredFormula | undefined | null,
|
|
152
404
|
data: DataModelValues | undefined,
|
|
153
|
-
|
|
405
|
+
dateFieldPaths?: Set<string>,
|
|
406
|
+
holidays?: string[],
|
|
407
|
+
): number | null {
|
|
154
408
|
if (!formula?.tokens?.length) {
|
|
155
|
-
return
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!hasAllFieldValues(formula, data, dateFieldPaths)) {
|
|
413
|
+
return null;
|
|
156
414
|
}
|
|
415
|
+
|
|
416
|
+
if (dateFieldPaths?.size) {
|
|
417
|
+
const resolved = resolveDateAwareTokens(formula, data, dateFieldPaths);
|
|
418
|
+
const holidaySet = buildHolidaySet(holidays);
|
|
419
|
+
const result = evalDateAwareTokens(resolved, holidaySet);
|
|
420
|
+
return result.value;
|
|
421
|
+
}
|
|
422
|
+
|
|
157
423
|
const resolved = resolveTokens(formula, data);
|
|
158
424
|
return evalResolvedTokens(resolved);
|
|
159
425
|
}
|
|
@@ -26,7 +26,7 @@ export interface ExpressionPart {
|
|
|
26
26
|
value: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// Field paths may contain hyphens (e.g
|
|
29
|
+
// Field paths may contain hyphens (e.g., fillable_recipient_uuid with UUIDs)
|
|
30
30
|
const EXPRESSION_REGEX = /[A-Za-z_][A-Za-z0-9_.-]*|\d+(?:\.\d+)?|[()+\-*/]|\s+|./g;
|
|
31
31
|
|
|
32
32
|
export function tokenizeExpression(expression: string): ExpressionPart[] {
|
|
@@ -98,57 +98,54 @@ export function parseExpression(
|
|
|
98
98
|
return tokens;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
export interface
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
export interface NormalizeMergeTagsResult {
|
|
102
|
+
normalized: string;
|
|
103
|
+
removed: { start: number; end: number } | null;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* - If there is exactly one space: remove the left field and the space.
|
|
111
|
-
* Otherwise, returns null.
|
|
107
|
+
* When two merge tags (valid field paths) become adjacent with no separator,
|
|
108
|
+
* removes the LEFT tag so merge tags stay atomic. Returns the normalized
|
|
109
|
+
* expression and the removed range (for caret adjustment).
|
|
112
110
|
*/
|
|
113
|
-
export function
|
|
111
|
+
export function normalizeMergeTags(
|
|
114
112
|
expression: string,
|
|
115
|
-
|
|
116
|
-
):
|
|
117
|
-
if (
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
const parts = tokenizeExpression(expression);
|
|
121
|
-
let pos = 0;
|
|
122
|
-
const withPos: { part: ExpressionPart; start: number; end: number }[] = [];
|
|
123
|
-
for (const part of parts) {
|
|
124
|
-
withPos.push({ part, start: pos, end: pos + part.value.length });
|
|
125
|
-
pos += part.value.length;
|
|
126
|
-
}
|
|
127
|
-
const deleteIdx = caretIndex - 1;
|
|
128
|
-
const spacePartIdx = withPos.findIndex(p => p.start <= deleteIdx && deleteIdx < p.end);
|
|
129
|
-
if (spacePartIdx < 0) {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
const spacePart = withPos[spacePartIdx];
|
|
133
|
-
if (spacePart.part.type !== 'text' || !/^\s+$/.test(spacePart.part.value)) {
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
const prevIdx = spacePartIdx - 1;
|
|
137
|
-
const nextIdx = spacePartIdx + 1;
|
|
138
|
-
if (prevIdx < 0 || nextIdx >= withPos.length) {
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
141
|
-
if (withPos[prevIdx].part.type !== 'field' || withPos[nextIdx].part.type !== 'field') {
|
|
142
|
-
return null;
|
|
113
|
+
validPaths: Set<string>,
|
|
114
|
+
): NormalizeMergeTagsResult {
|
|
115
|
+
if (validPaths.size === 0) {
|
|
116
|
+
return { normalized: expression, removed: null };
|
|
143
117
|
}
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
118
|
+
const pathsByLength = [...validPaths].sort((a, b) => b.length - a.length);
|
|
119
|
+
for (let i = 0; i < expression.length; i++) {
|
|
120
|
+
for (const path1 of pathsByLength) {
|
|
121
|
+
if (i + path1.length > expression.length) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const slice1 = expression.slice(i, i + path1.length);
|
|
125
|
+
if (slice1 !== path1) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const beforeFirst = expression[i - 1];
|
|
129
|
+
if (beforeFirst !== undefined && /[A-Za-z0-9_.-]/.test(beforeFirst)) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const restStart = i + path1.length;
|
|
133
|
+
for (const path2 of pathsByLength) {
|
|
134
|
+
if (restStart + path2.length > expression.length) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const slice2 = expression.slice(restStart, restStart + path2.length);
|
|
138
|
+
if (slice2 !== path2) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const afterSecond = expression[restStart + path2.length];
|
|
142
|
+
if (afterSecond !== undefined && /[A-Za-z0-9_.-]/.test(afterSecond)) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const normalized = expression.slice(0, i) + expression.slice(restStart);
|
|
146
|
+
return { normalized, removed: { start: i, end: restStart } };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
149
|
}
|
|
150
|
-
|
|
151
|
-
expression.slice(0, withPos[prevIdx].start) + expression.slice(withPos[nextIdx].start);
|
|
152
|
-
const newCaret = withPos[prevIdx].start;
|
|
153
|
-
return { newExpression, newCaret };
|
|
150
|
+
return { normalized: expression, removed: null };
|
|
154
151
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_DATE_FORMAT } from '../../constants';
|
|
1
2
|
import { CalculatedFieldFormat } from '../../interface/types';
|
|
2
3
|
|
|
3
4
|
function roundValue(
|
|
@@ -36,9 +37,33 @@ function formatIntegerPart(
|
|
|
36
37
|
return parts.join(sep);
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
const DATE_FORMAT_TOKENS: Record<string, (d: Date) => string> = {
|
|
41
|
+
YYYY: d => String(d.getFullYear()),
|
|
42
|
+
YY: d => String(d.getFullYear()).slice(-2),
|
|
43
|
+
MMMM: d => d.toLocaleString('en-US', { month: 'long' }),
|
|
44
|
+
MMM: d => d.toLocaleString('en-US', { month: 'short' }),
|
|
45
|
+
MM: d => String(d.getMonth() + 1).padStart(2, '0'),
|
|
46
|
+
M: d => String(d.getMonth() + 1),
|
|
47
|
+
DD: d => String(d.getDate()).padStart(2, '0'),
|
|
48
|
+
D: d => String(d.getDate()),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const DATE_TOKEN_PATTERN = new RegExp(Object.keys(DATE_FORMAT_TOKENS).join('|'), 'g');
|
|
52
|
+
|
|
53
|
+
function formatDateValue(epochMs: number, dateFormat: string): string {
|
|
54
|
+
const date = new Date(epochMs);
|
|
55
|
+
if (isNaN(date.getTime())) {
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
return dateFormat.replace(DATE_TOKEN_PATTERN, match => {
|
|
59
|
+
const fn = DATE_FORMAT_TOKENS[match];
|
|
60
|
+
return fn ? fn(date) : match;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
39
64
|
/**
|
|
40
65
|
* 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.
|
|
66
|
+
* Applies rounding, decimal places, thousands/decimal separators, result type (number/currency/percent/date), and prefix/postfix.
|
|
42
67
|
*/
|
|
43
68
|
export function formatCalculatedResult(
|
|
44
69
|
value: number,
|
|
@@ -48,23 +73,21 @@ export function formatCalculatedResult(
|
|
|
48
73
|
return String(value);
|
|
49
74
|
}
|
|
50
75
|
|
|
76
|
+
if (format.resultType === 'date') {
|
|
77
|
+
return formatDateValue(value, format.dateFormat ?? DEFAULT_DATE_FORMAT);
|
|
78
|
+
}
|
|
79
|
+
|
|
51
80
|
const {
|
|
52
81
|
decimalSeparator,
|
|
53
82
|
decimalSeparatorEnabled,
|
|
54
83
|
decimals,
|
|
55
84
|
postfixText,
|
|
56
85
|
prefixText,
|
|
57
|
-
resultType,
|
|
58
86
|
roundingMode,
|
|
59
87
|
thousandsSeparator,
|
|
60
88
|
} = format;
|
|
61
89
|
|
|
62
|
-
|
|
63
|
-
if (resultType === 'percent') {
|
|
64
|
-
num = value * 100;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const rounded = roundValue(num, decimals, roundingMode);
|
|
90
|
+
const rounded = roundValue(value, decimals, roundingMode);
|
|
68
91
|
const fixed = rounded.toFixed(decimals);
|
|
69
92
|
const [intPart, decPart] = fixed.split('.');
|
|
70
93
|
|
|
@@ -73,7 +96,6 @@ export function formatCalculatedResult(
|
|
|
73
96
|
const decSuffix = decimals > 0 ? decimalSep + (decPart ?? '') : '';
|
|
74
97
|
|
|
75
98
|
const numberStr = intFormatted + decSuffix;
|
|
76
|
-
const suffix = resultType === 'percent' ? '%' : '';
|
|
77
99
|
|
|
78
|
-
return `${prefixText}${numberStr}${
|
|
100
|
+
return `${prefixText}${numberStr}${postfixText}`;
|
|
79
101
|
}
|
|
@@ -4,7 +4,5 @@ export * from './evaluate-formula.utils';
|
|
|
4
4
|
export * from './expression.utils';
|
|
5
5
|
export * from './format-calculated-result.utils';
|
|
6
6
|
export * from './formula-types';
|
|
7
|
-
export * from './referenced-paths.utils';
|
|
8
7
|
export * from './render-formula.utils';
|
|
9
|
-
export * from './serialize-formula.utils';
|
|
10
8
|
export * from './validate-formula.utils';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MAX_DATE_CALC_DAYS } from '../../constants';
|
|
1
2
|
import { FormulaToken, StructuredFormula } from '../../interface/types';
|
|
2
3
|
|
|
3
4
|
export interface FormulaValidationResult {
|
|
@@ -20,6 +21,7 @@ function isOperandToken(t: FormulaToken): boolean {
|
|
|
20
21
|
export function validateFormula(
|
|
21
22
|
formula: StructuredFormula | undefined | null,
|
|
22
23
|
validPaths: Set<string>,
|
|
24
|
+
knownDateFields?: Set<string>,
|
|
23
25
|
): FormulaValidationResult {
|
|
24
26
|
const errors: string[] = [];
|
|
25
27
|
|
|
@@ -90,6 +92,28 @@ export function validateFormula(
|
|
|
90
92
|
errors.push('Formula must end with an operand or closing parenthesis');
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
if (knownDateFields?.size) {
|
|
96
|
+
const hasDateField = tokens.some(t => t.type === 'field' && knownDateFields.has(t.path));
|
|
97
|
+
if (hasDateField) {
|
|
98
|
+
const hasMultiplyOrDivide = tokens.some(
|
|
99
|
+
t => t.type === 'operator' && (t.value === '*' || t.value === '/'),
|
|
100
|
+
);
|
|
101
|
+
if (hasMultiplyOrDivide) {
|
|
102
|
+
errors.push(
|
|
103
|
+
'Multiply (*) and divide (/) operators cannot be used with date fields. Only +, -, and parentheses are allowed.',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const hasLargeNumber = tokens.some(
|
|
107
|
+
t => t.type === 'number' && Math.abs(Number(t.value)) > MAX_DATE_CALC_DAYS,
|
|
108
|
+
);
|
|
109
|
+
if (hasLargeNumber) {
|
|
110
|
+
errors.push(
|
|
111
|
+
`Number of days cannot exceed ${MAX_DATE_CALC_DAYS} in date calculations.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
93
117
|
return {
|
|
94
118
|
valid: errors.length === 0,
|
|
95
119
|
errors,
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { StructuredFormula } from '../../interface/types';
|
|
2
|
-
/**
|
|
3
|
-
* Returns the set of data model field paths referenced in the formula.
|
|
4
|
-
* Used for recalculation scope: only these fields should trigger formula updates.
|
|
5
|
-
*/
|
|
6
|
-
export declare function getReferencedPaths(formula: StructuredFormula | undefined | null): string[];
|
|
7
|
-
//# sourceMappingURL=referenced-paths.utils.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"referenced-paths.utils.d.ts","sourceRoot":"","sources":["../../../src/utils/formula/referenced-paths.utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAE1D;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,iBAAiB,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,EAAE,CAW1F"}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Returns the set of data model field paths referenced in the formula.
|
|
3
|
-
* Used for recalculation scope: only these fields should trigger formula updates.
|
|
4
|
-
*/
|
|
5
|
-
export function getReferencedPaths(formula) {
|
|
6
|
-
var _a;
|
|
7
|
-
if (!((_a = formula === null || formula === void 0 ? void 0 : formula.tokens) === null || _a === void 0 ? void 0 : _a.length)) {
|
|
8
|
-
return [];
|
|
9
|
-
}
|
|
10
|
-
const paths = [];
|
|
11
|
-
for (const token of formula.tokens) {
|
|
12
|
-
if (token.type === 'field') {
|
|
13
|
-
paths.push(token.path);
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
return paths;
|
|
17
|
-
}
|
|
18
|
-
//# sourceMappingURL=referenced-paths.utils.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"referenced-paths.utils.js","sourceRoot":"","sources":["../../../src/utils/formula/referenced-paths.utils.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,OAA6C;;IAC5E,IAAI,CAAC,CAAA,MAAA,OAAO,aAAP,OAAO,uBAAP,OAAO,CAAE,MAAM,0CAAE,MAAM,CAAA,EAAE,CAAC;QAC3B,OAAO,EAAE,CAAC;IACd,CAAC;IACD,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,MAAM,KAAK,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;QACjC,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;YACzB,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC3B,CAAC;IACL,CAAC;IACD,OAAO,KAAK,CAAC;AACjB,CAAC"}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { StructuredFormula } from '../../interface/types';
|
|
2
|
-
export interface SerializedFormula {
|
|
3
|
-
v: number;
|
|
4
|
-
tokens: StructuredFormula['tokens'];
|
|
5
|
-
}
|
|
6
|
-
/**
|
|
7
|
-
* Serialize structured formula for persistence (e.g. to JSON in store).
|
|
8
|
-
*/
|
|
9
|
-
export declare function serializeFormula(formula: StructuredFormula): string;
|
|
10
|
-
/**
|
|
11
|
-
* Deserialize formula from persisted string. Returns null if invalid.
|
|
12
|
-
*/
|
|
13
|
-
export declare function deserializeFormula(json: string): StructuredFormula | null;
|
|
14
|
-
//# sourceMappingURL=serialize-formula.utils.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"serialize-formula.utils.d.ts","sourceRoot":"","sources":["../../../src/utils/formula/serialize-formula.utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAI1D,MAAM,WAAW,iBAAiB;IAC9B,CAAC,EAAE,MAAM,CAAC;IACV,MAAM,EAAE,iBAAiB,CAAC,QAAQ,CAAC,CAAC;CACvC;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,iBAAiB,GAAG,MAAM,CAMnE;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAgBzE"}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
const SERIALIZATION_VERSION = 1;
|
|
2
|
-
/**
|
|
3
|
-
* Serialize structured formula for persistence (e.g. to JSON in store).
|
|
4
|
-
*/
|
|
5
|
-
export function serializeFormula(formula) {
|
|
6
|
-
const payload = {
|
|
7
|
-
v: SERIALIZATION_VERSION,
|
|
8
|
-
tokens: formula.tokens,
|
|
9
|
-
};
|
|
10
|
-
return JSON.stringify(payload);
|
|
11
|
-
}
|
|
12
|
-
/**
|
|
13
|
-
* Deserialize formula from persisted string. Returns null if invalid.
|
|
14
|
-
*/
|
|
15
|
-
export function deserializeFormula(json) {
|
|
16
|
-
if (!json || typeof json !== 'string') {
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
try {
|
|
20
|
-
const payload = JSON.parse(json);
|
|
21
|
-
if ((payload === null || payload === void 0 ? void 0 : payload.v) !== SERIALIZATION_VERSION || !Array.isArray(payload.tokens)) {
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
if (payload.tokens.length === 0) {
|
|
25
|
-
return { tokens: [] };
|
|
26
|
-
}
|
|
27
|
-
return { tokens: payload.tokens };
|
|
28
|
-
}
|
|
29
|
-
catch (_a) {
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
//# sourceMappingURL=serialize-formula.utils.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"serialize-formula.utils.js","sourceRoot":"","sources":["../../../src/utils/formula/serialize-formula.utils.ts"],"names":[],"mappings":"AAEA,MAAM,qBAAqB,GAAG,CAAC,CAAC;AAOhC;;GAEG;AACH,MAAM,UAAU,gBAAgB,CAAC,OAA0B;IACvD,MAAM,OAAO,GAAsB;QAC/B,CAAC,EAAE,qBAAqB;QACxB,MAAM,EAAE,OAAO,CAAC,MAAM;KACzB,CAAC;IACF,OAAO,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;AACnC,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAY;IAC3C,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;QACpC,OAAO,IAAI,CAAC;IAChB,CAAC;IACD,IAAI,CAAC;QACD,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAsB,CAAC;QACtD,IAAI,CAAA,OAAO,aAAP,OAAO,uBAAP,OAAO,CAAE,CAAC,MAAK,qBAAqB,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YACzE,OAAO,IAAI,CAAC;QAChB,CAAC;QACD,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC9B,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;QAC1B,CAAC;QACD,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC;IACtC,CAAC;IAAC,WAAM,CAAC;QACL,OAAO,IAAI,CAAC;IAChB,CAAC;AACL,CAAC"}
|
|
@@ -1,18 +0,0 @@
|
|
|
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
|
-
}
|