@servicetitan/dte-pdf-editor 1.45.0 → 1.47.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 (76) hide show
  1. package/README.md +15 -0
  2. package/dist/components/field-config-panel/field-config-panel.d.ts.map +1 -1
  3. package/dist/components/field-config-panel/field-config-panel.js +4 -2
  4. package/dist/components/field-config-panel/field-config-panel.js.map +1 -1
  5. package/dist/components/pdf-fields-overlay/pdf-fields-overlay.d.ts.map +1 -1
  6. package/dist/components/pdf-fields-overlay/pdf-fields-overlay.js +11 -1
  7. package/dist/components/pdf-fields-overlay/pdf-fields-overlay.js.map +1 -1
  8. package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.d.ts +1 -0
  9. package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.d.ts.map +1 -1
  10. package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.js +14 -2
  11. package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.js.map +1 -1
  12. package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.d.ts.map +1 -1
  13. package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.js +3 -0
  14. package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.js.map +1 -1
  15. package/dist/components/pdf-fields-overlay/pdf-overlay-field.d.ts +1 -0
  16. package/dist/components/pdf-fields-overlay/pdf-overlay-field.d.ts.map +1 -1
  17. package/dist/components/pdf-fields-overlay/pdf-overlay-field.js +2 -2
  18. package/dist/components/pdf-fields-overlay/pdf-overlay-field.js.map +1 -1
  19. package/dist/components/pdf-view/pdf-view-calculated.d.ts +2 -1
  20. package/dist/components/pdf-view/pdf-view-calculated.d.ts.map +1 -1
  21. package/dist/components/pdf-view/pdf-view-calculated.js +5 -2
  22. package/dist/components/pdf-view/pdf-view-calculated.js.map +1 -1
  23. package/dist/components/pdf-view/pdf-view-fillable.d.ts +10 -2
  24. package/dist/components/pdf-view/pdf-view-fillable.d.ts.map +1 -1
  25. package/dist/components/pdf-view/pdf-view-fillable.js +16 -12
  26. package/dist/components/pdf-view/pdf-view-fillable.js.map +1 -1
  27. package/dist/components/pdf-view/pdf-view.d.ts +2 -2
  28. package/dist/components/pdf-view/pdf-view.d.ts.map +1 -1
  29. package/dist/components/pdf-view/pdf-view.js +4 -4
  30. package/dist/components/pdf-view/pdf-view.js.map +1 -1
  31. package/dist/interface/types.d.ts +2 -1
  32. package/dist/interface/types.d.ts.map +1 -1
  33. package/dist/interface/types.js.map +1 -1
  34. package/dist/utils/formula/expression.utils.d.ts +13 -0
  35. package/dist/utils/formula/expression.utils.d.ts.map +1 -1
  36. package/dist/utils/formula/expression.utils.js +42 -0
  37. package/dist/utils/formula/expression.utils.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/__tests__/field-types/date-fields.test.ts +212 -0
  40. package/src/__tests__/field-types/display-condition-fields.test.ts +340 -0
  41. package/src/__tests__/field-types/e-sign-fields.test.ts +97 -0
  42. package/src/__tests__/field-types/fillable-fields.test.ts +180 -0
  43. package/src/__tests__/field-types/form-fields.test.ts +193 -0
  44. package/src/__tests__/field-types/formula-fields.test.ts +245 -0
  45. package/src/__tests__/field-types/merge-tag-fields.test.ts +208 -0
  46. package/src/components/field-config-panel/field-config-panel.tsx +11 -0
  47. package/src/components/pdf-fields-overlay/pdf-fields-overlay.tsx +12 -1
  48. package/src/components/pdf-fields-overlay/pdf-overlay-field-calculated.tsx +20 -4
  49. package/src/components/pdf-fields-overlay/pdf-overlay-field-fillable.tsx +10 -0
  50. package/src/components/pdf-fields-overlay/pdf-overlay-field.tsx +5 -1
  51. package/src/components/pdf-view/pdf-view-calculated.tsx +13 -3
  52. package/src/components/pdf-view/pdf-view-fillable.tsx +62 -21
  53. package/src/components/pdf-view/pdf-view.tsx +11 -10
  54. package/src/interface/types.ts +12 -10
  55. package/src/styles/generic.css +6 -0
  56. package/src/styles/inline-editable.css +1 -0
  57. package/src/utils/conditions/__tests__/evaluate.utils.test.ts +163 -0
  58. package/src/utils/conditions/__tests__/schema-data-points.utils.test.ts +149 -0
  59. package/src/utils/data-model/__tests__/extract-fields.utils.test.ts +154 -0
  60. package/src/utils/data-model/__tests__/resolve-values.utils.test.ts +60 -0
  61. package/src/utils/field/__tests__/field-background-color.utils.test.ts +26 -0
  62. package/src/utils/field/__tests__/field-placeholder-text.utils.test.ts +46 -0
  63. package/src/utils/formula/__tests__/dom.utils.test.ts +33 -0
  64. package/src/utils/formula/__tests__/evaluate-formula.utils.test.ts +202 -0
  65. package/src/utils/formula/__tests__/expression.utils.test.ts +119 -0
  66. package/src/utils/formula/__tests__/form-fields.utils.test.ts +274 -0
  67. package/src/utils/formula/__tests__/format-calculated-result.utils.test.ts +105 -0
  68. package/src/utils/formula/__tests__/render-formula.utils.test.ts +43 -0
  69. package/src/utils/formula/__tests__/validate-formula.utils.test.ts +168 -0
  70. package/src/utils/formula/expression.utils.ts +44 -0
  71. package/src/utils/path/__tests__/generate-e-sign-path.test.ts +26 -0
  72. package/src/utils/path/__tests__/generate-fillable-path.test.ts +17 -0
  73. package/src/utils/path/__tests__/parse-fillable-path.test.ts +25 -0
  74. package/src/utils/recipients/__tests__/map-colors.test.ts +40 -0
  75. package/src/utils/shared/__tests__/date.utils.test.ts +58 -0
  76. package/src/utils/shared/__tests__/number.utils.test.ts +48 -0
@@ -0,0 +1,274 @@
1
+ import {
2
+ DataPointOption,
3
+ FieldTypeEnum,
4
+ FormFieldInfo,
5
+ FormFieldsByFormIdI,
6
+ FormInfo,
7
+ } from '../../../interface/types';
8
+ import {
9
+ buildFormFieldKey,
10
+ buildFormFieldSnapshot,
11
+ formFieldInfoToFieldTypeOption,
12
+ formFieldInfosToCalculationOptions,
13
+ formFieldToDisplayConditionDataPointOption,
14
+ getDisplayConditionFieldTypeForKey,
15
+ inferDisplayConditionSourceKind,
16
+ isFormFieldCalculationType,
17
+ normalizeFormFieldIdForPath,
18
+ parseFormFieldKey,
19
+ tryBuildFormFieldFormulaSnapshot,
20
+ } from '../form-fields.utils';
21
+
22
+ const buildFormFieldInfo = (overrides: Partial<FormFieldInfo> = {}): FormFieldInfo => ({
23
+ id: 'abc-123',
24
+ header: 'My Field',
25
+ itemType: 'number',
26
+ ...overrides,
27
+ });
28
+
29
+ const buildFormInfo = (overrides: Partial<FormInfo> = {}): FormInfo => ({
30
+ id: 7,
31
+ name: 'Form A',
32
+ ...overrides,
33
+ });
34
+
35
+ describe('normalizeFormFieldIdForPath', () => {
36
+ test('removes all hyphens from field id', () => {
37
+ expect(normalizeFormFieldIdForPath('abc-123-def')).toBe('abc123def');
38
+ });
39
+
40
+ test('returns id unchanged when no hyphens are present', () => {
41
+ expect(normalizeFormFieldIdForPath('abc123')).toBe('abc123');
42
+ });
43
+ });
44
+
45
+ describe('buildFormFieldKey', () => {
46
+ test('builds __submission_fields path with normalized field id', () => {
47
+ expect(buildFormFieldKey(7, 'abc-123')).toBe('__submission_fields.7.abc123');
48
+ });
49
+ });
50
+
51
+ describe('parseFormFieldKey', () => {
52
+ const subject = (key: string) => parseFormFieldKey(key);
53
+
54
+ test('parses canonical __submission_fields path', () => {
55
+ expect(subject('__submission_fields.7.abc123')).toEqual({
56
+ formId: 7,
57
+ fieldId: 'abc123',
58
+ });
59
+ });
60
+
61
+ test('parses legacy __submission_field (singular) path with hyphens', () => {
62
+ expect(subject('__submission_field.7.abc-123')).toEqual({
63
+ formId: 7,
64
+ fieldId: 'abc123',
65
+ });
66
+ });
67
+
68
+ test('parses legacy __form_ path', () => {
69
+ expect(subject('__form_7_abc123')).toEqual({ formId: 7, fieldId: 'abc123' });
70
+ });
71
+
72
+ test('returns null for unrelated paths', () => {
73
+ expect(subject('user.name')).toBeNull();
74
+ });
75
+ });
76
+
77
+ describe('buildFormFieldSnapshot', () => {
78
+ test('builds a snapshot from form info and field', () => {
79
+ expect(buildFormFieldSnapshot(7, 'Form A', buildFormFieldInfo())).toEqual({
80
+ formId: 7,
81
+ fieldId: 'abc-123',
82
+ formName: 'Form A',
83
+ fieldName: 'My Field',
84
+ fieldType: 'number',
85
+ });
86
+ });
87
+ });
88
+
89
+ describe('tryBuildFormFieldFormulaSnapshot', () => {
90
+ const forms: FormInfo[] = [buildFormInfo()];
91
+ const formFieldsByFormId: FormFieldsByFormIdI = {
92
+ 7: [buildFormFieldInfo()],
93
+ };
94
+ const subject = (path: string) =>
95
+ tryBuildFormFieldFormulaSnapshot(path, forms, formFieldsByFormId);
96
+
97
+ test('returns snapshot when form and field exist', () => {
98
+ const snapshot = subject('__submission_fields.7.abc123');
99
+ expect(snapshot).toEqual({
100
+ formId: 7,
101
+ fieldId: 'abc-123',
102
+ formName: 'Form A',
103
+ fieldName: 'My Field',
104
+ fieldType: 'number',
105
+ });
106
+ });
107
+
108
+ test('returns undefined when path is not a form field key', () => {
109
+ expect(subject('user.name')).toBeUndefined();
110
+ });
111
+
112
+ test('returns undefined when form is missing', () => {
113
+ expect(subject('__submission_fields.99.abc123')).toBeUndefined();
114
+ });
115
+
116
+ test('returns undefined when field id does not match any form field', () => {
117
+ expect(subject('__submission_fields.7.unknown')).toBeUndefined();
118
+ });
119
+ });
120
+
121
+ describe('isFormFieldCalculationType', () => {
122
+ test.each([
123
+ ['number', true],
124
+ ['date', true],
125
+ ['text', false],
126
+ ] as const)('returns %s for itemType %s', (itemType, expected) => {
127
+ expect(isFormFieldCalculationType(itemType)).toBe(expected);
128
+ });
129
+ });
130
+
131
+ describe('formFieldInfosToCalculationOptions', () => {
132
+ test('includes only number and date fields with calculation metadata', () => {
133
+ const fields: FormFieldInfo[] = [
134
+ buildFormFieldInfo({ id: 'num', itemType: 'number', header: 'Num' }),
135
+ buildFormFieldInfo({ id: 'dt', itemType: 'date', header: 'Date' }),
136
+ buildFormFieldInfo({ id: 'txt', itemType: 'text', header: 'Text' }),
137
+ ];
138
+
139
+ const result = formFieldInfosToCalculationOptions(7, fields, 'Form A');
140
+
141
+ expect(result).toHaveLength(2);
142
+ expect(result.map(r => r.label)).toEqual(['Num', 'Date']);
143
+ expect(result[0].type).toBe(FieldTypeEnum.forms);
144
+ expect(result[0].path).toBe('__submission_fields.7.num');
145
+ expect(result[0].formSnapshot).toEqual(
146
+ expect.objectContaining({ formId: 7, fieldId: 'num', fieldName: 'Num' }),
147
+ );
148
+ });
149
+ });
150
+
151
+ describe('formFieldInfoToFieldTypeOption', () => {
152
+ test('omits fieldType for non-calculation fields', () => {
153
+ const result = formFieldInfoToFieldTypeOption(
154
+ 7,
155
+ buildFormFieldInfo({ itemType: 'text' }),
156
+ 'Form A',
157
+ );
158
+ expect(result.fieldType).toBeUndefined();
159
+ });
160
+
161
+ test('includes fieldType for calculation fields', () => {
162
+ const result = formFieldInfoToFieldTypeOption(
163
+ 7,
164
+ buildFormFieldInfo({ itemType: 'date' }),
165
+ 'Form A',
166
+ );
167
+ expect(result.fieldType).toBe('date');
168
+ });
169
+ });
170
+
171
+ describe('formFieldToDisplayConditionDataPointOption', () => {
172
+ test.each([
173
+ ['number', 'number'],
174
+ ['date', 'string'],
175
+ ['text', 'string'],
176
+ ] as const)('maps itemType %s to fieldType %s', (itemType, expected) => {
177
+ const option = formFieldToDisplayConditionDataPointOption(
178
+ buildFormInfo(),
179
+ buildFormFieldInfo({ itemType }),
180
+ );
181
+ expect(option.fieldType).toBe(expected);
182
+ });
183
+
184
+ test('uses field header as title and snapshot in option', () => {
185
+ const option = formFieldToDisplayConditionDataPointOption(
186
+ buildFormInfo(),
187
+ buildFormFieldInfo(),
188
+ );
189
+ expect(option.title).toBe('My Field');
190
+ expect(option.fullKey).toBe('__submission_fields.7.abc123');
191
+ expect(option.formSnapshot).toBeDefined();
192
+ });
193
+ });
194
+
195
+ describe('getDisplayConditionFieldTypeForKey', () => {
196
+ const mergeTagOptions: DataPointOption[] = [
197
+ { fieldType: 'string', fullKey: 'mt.name', title: 'Name' },
198
+ { fieldType: 'number', fullKey: 'mt.age', title: 'Age' },
199
+ ];
200
+ const fillableOptions: DataPointOption[] = [
201
+ { fieldType: 'string', fullKey: 'fillable_a_b', title: 'A' },
202
+ ];
203
+
204
+ test('returns fieldType from merge tag options', () => {
205
+ expect(
206
+ getDisplayConditionFieldTypeForKey('mt.age', mergeTagOptions, fillableOptions, {}),
207
+ ).toBe('number');
208
+ });
209
+
210
+ test('returns fieldType from fillable options', () => {
211
+ expect(
212
+ getDisplayConditionFieldTypeForKey('fillable_a_b', mergeTagOptions, fillableOptions, {}),
213
+ ).toBe('string');
214
+ });
215
+
216
+ test('returns number for form field with number itemType', () => {
217
+ const formFieldsByFormId: FormFieldsByFormIdI = {
218
+ 7: [buildFormFieldInfo({ id: 'num', itemType: 'number' })],
219
+ };
220
+ expect(
221
+ getDisplayConditionFieldTypeForKey(
222
+ '__submission_fields.7.num',
223
+ mergeTagOptions,
224
+ fillableOptions,
225
+ formFieldsByFormId,
226
+ ),
227
+ ).toBe('number');
228
+ });
229
+
230
+ test('returns string for unknown data point key', () => {
231
+ expect(
232
+ getDisplayConditionFieldTypeForKey('unknown.path', mergeTagOptions, fillableOptions, {}),
233
+ ).toBe('string');
234
+ });
235
+ });
236
+
237
+ describe('inferDisplayConditionSourceKind', () => {
238
+ const mergeTagOptions: DataPointOption[] = [
239
+ { fieldType: 'string', fullKey: 'mt.name', title: 'Name' },
240
+ ];
241
+ const fillableOptions: DataPointOption[] = [
242
+ { fieldType: 'string', fullKey: 'fillable_a_b', title: 'A' },
243
+ ];
244
+
245
+ test('returns null for empty key', () => {
246
+ expect(inferDisplayConditionSourceKind('', mergeTagOptions, fillableOptions)).toBeNull();
247
+ });
248
+
249
+ test('returns forms for a form field path', () => {
250
+ expect(
251
+ inferDisplayConditionSourceKind(
252
+ '__submission_fields.7.abc',
253
+ mergeTagOptions,
254
+ fillableOptions,
255
+ ),
256
+ ).toBe(FieldTypeEnum.forms);
257
+ });
258
+
259
+ test('returns fillable for a fillable path', () => {
260
+ expect(
261
+ inferDisplayConditionSourceKind('fillable_a_b', mergeTagOptions, fillableOptions),
262
+ ).toBe(FieldTypeEnum.fillable);
263
+ });
264
+
265
+ test('returns dataModel for a merge tag path', () => {
266
+ expect(inferDisplayConditionSourceKind('mt.name', mergeTagOptions, fillableOptions)).toBe(
267
+ FieldTypeEnum.dataModel,
268
+ );
269
+ });
270
+
271
+ test('returns null for unknown key', () => {
272
+ expect(inferDisplayConditionSourceKind('unknown', mergeTagOptions, fillableOptions)).toBeNull();
273
+ });
274
+ });
@@ -0,0 +1,105 @@
1
+ import {
2
+ CalculatedFieldFormat,
3
+ CalculatedFormatForDate,
4
+ CalculatedFormatForNumber,
5
+ } from '../../../interface/types';
6
+ import { formatCalculatedResult } from '../format-calculated-result.utils';
7
+
8
+ const baseNumberFormat: CalculatedFormatForNumber = {
9
+ resultType: 'number',
10
+ thousandsSeparator: false,
11
+ decimals: 2,
12
+ roundingMode: 'round',
13
+ decimalSeparatorEnabled: false,
14
+ decimalSeparator: '.',
15
+ prefixText: '',
16
+ postfixText: '',
17
+ };
18
+
19
+ describe('formatCalculatedResult', () => {
20
+ const subject = (value: number, format: CalculatedFieldFormat | undefined) =>
21
+ formatCalculatedResult(value, format);
22
+
23
+ describe('with non-finite values', () => {
24
+ test.each([NaN, Infinity, -Infinity])('returns empty string for %p', value => {
25
+ expect(subject(value, baseNumberFormat)).toBe('');
26
+ });
27
+ });
28
+
29
+ describe('when format is undefined', () => {
30
+ test('returns the raw value as string', () => {
31
+ expect(subject(1234.5, undefined)).toBe('1234.5');
32
+ });
33
+ });
34
+
35
+ describe('with number formatting', () => {
36
+ test('rounds to the specified decimals', () => {
37
+ expect(subject(1.236, { ...baseNumberFormat, decimals: 2 })).toBe('1.24');
38
+ });
39
+
40
+ test('floors when roundingMode is floor', () => {
41
+ expect(subject(1.999, { ...baseNumberFormat, decimals: 0, roundingMode: 'floor' })).toBe(
42
+ '1',
43
+ );
44
+ });
45
+
46
+ test('ceils when roundingMode is ceil', () => {
47
+ expect(subject(1.001, { ...baseNumberFormat, decimals: 0, roundingMode: 'ceil' })).toBe(
48
+ '2',
49
+ );
50
+ });
51
+
52
+ test('adds thousands separator when enabled', () => {
53
+ expect(
54
+ subject(1234567.89, {
55
+ ...baseNumberFormat,
56
+ decimals: 2,
57
+ thousandsSeparator: true,
58
+ }),
59
+ ).toBe('1,234,567.89');
60
+ });
61
+
62
+ test('skips decimal part when decimals is 0', () => {
63
+ expect(subject(1234, { ...baseNumberFormat, decimals: 0 })).toBe('1234');
64
+ });
65
+
66
+ test('applies prefix and postfix text', () => {
67
+ expect(
68
+ subject(50, {
69
+ ...baseNumberFormat,
70
+ decimals: 0,
71
+ prefixText: '$',
72
+ postfixText: ' USD',
73
+ }),
74
+ ).toBe('$50 USD');
75
+ });
76
+ });
77
+
78
+ describe('with date formatting', () => {
79
+ const baseDateFormat: CalculatedFormatForDate = {
80
+ resultType: 'date',
81
+ dateFormat: 'MM/DD/YYYY',
82
+ };
83
+
84
+ // Display range is +/- MAX_DATE_DISPLAY_RANGE_YEARS years from "now",
85
+ // so use today's date to stay within range.
86
+ const today = new Date();
87
+ const todayMs = today.getTime();
88
+
89
+ test('formats epoch milliseconds with default tokens', () => {
90
+ const result = subject(todayMs, baseDateFormat);
91
+ expect(result).toMatch(/^\d{2}\/\d{2}\/\d{4}$/);
92
+ });
93
+
94
+ test('substitutes all supported date tokens', () => {
95
+ const year = String(today.getFullYear());
96
+ const result = subject(todayMs, { resultType: 'date', dateFormat: 'YYYY-YY' });
97
+ expect(result).toBe(`${year}-${year.slice(-2)}`);
98
+ });
99
+
100
+ test('returns empty string when date is far outside display range', () => {
101
+ const futureDate = Date.UTC(2100, 0, 1);
102
+ expect(subject(futureDate, baseDateFormat)).toBe('');
103
+ });
104
+ });
105
+ });
@@ -0,0 +1,43 @@
1
+ import { renderFormulaHtml } from '../render-formula.utils';
2
+
3
+ describe('renderFormulaHtml', () => {
4
+ const subject = (expression: string, labelMap: Map<string, string>) =>
5
+ renderFormulaHtml(expression, labelMap);
6
+
7
+ test('returns empty string when expression is blank', () => {
8
+ expect(subject(' ', new Map())).toBe('');
9
+ });
10
+
11
+ test('renders field tokens as chip spans with label and remove button', () => {
12
+ const html = subject('userName', new Map([['userName', 'User Name']]));
13
+ expect(html).toContain('class="dte-formula-chip"');
14
+ expect(html).toContain('data-field="userName"');
15
+ expect(html).toContain('data-field-index="0"');
16
+ expect(html).toContain('User Name');
17
+ expect(html).toContain('data-field-remove="true"');
18
+ });
19
+
20
+ test('falls back to the field value when label is missing', () => {
21
+ const html = subject('unknown', new Map());
22
+ expect(html).toContain('>unknown<');
23
+ });
24
+
25
+ test('increments data-field-index for each field token', () => {
26
+ const html = subject('a b', new Map());
27
+ expect(html).toContain('data-field-index="0"');
28
+ expect(html).toContain('data-field-index="1"');
29
+ });
30
+
31
+ test('escapes special characters in non-field tokens', () => {
32
+ expect(subject('<', new Map())).toContain('&lt;');
33
+ });
34
+
35
+ test('replaces spaces in non-field tokens with non-breaking spaces', () => {
36
+ const html = subject('a b', new Map());
37
+ expect(html).toContain('&nbsp;&nbsp;&nbsp;');
38
+ });
39
+
40
+ test('returns empty string for whitespace-only expression', () => {
41
+ expect(subject(' ', new Map())).toBe('');
42
+ });
43
+ });
@@ -0,0 +1,168 @@
1
+ import { StructuredFormula } from '../../../interface/types';
2
+ import { validateFormula } from '../validate-formula.utils';
3
+
4
+ const f = (tokens: StructuredFormula['tokens']): StructuredFormula => ({ tokens });
5
+
6
+ describe('validateFormula', () => {
7
+ const subject = (
8
+ formula: StructuredFormula | undefined | null,
9
+ validPaths: Set<string>,
10
+ knownDateFields?: Set<string>,
11
+ pathToLabel?: Map<string, string>,
12
+ ) => validateFormula(formula, validPaths, knownDateFields, pathToLabel);
13
+
14
+ describe('when formula is empty', () => {
15
+ test.each([
16
+ ['undefined', undefined],
17
+ ['null', null],
18
+ ['empty tokens', f([])],
19
+ ])('returns invalid with empty error for %s', (_, formula) => {
20
+ const result = subject(formula, new Set());
21
+ expect(result.valid).toBe(false);
22
+ expect(result.errors).toEqual(['Formula cannot be empty']);
23
+ });
24
+ });
25
+
26
+ describe('when formula is valid', () => {
27
+ test('returns valid for a number-only formula', () => {
28
+ expect(subject(f([{ type: 'number', value: '42' }]), new Set())).toEqual({
29
+ valid: true,
30
+ errors: [],
31
+ });
32
+ });
33
+
34
+ test('returns valid for a known field reference', () => {
35
+ const result = subject(
36
+ f([{ type: 'field', path: 'a.b', fieldType: 'number' }]),
37
+ new Set(['a.b']),
38
+ );
39
+ expect(result.valid).toBe(true);
40
+ });
41
+
42
+ test('returns valid for a balanced expression with operators and parens', () => {
43
+ const result = subject(
44
+ f([
45
+ { type: 'lparen' },
46
+ { type: 'number', value: '1' },
47
+ { type: 'operator', value: '+' },
48
+ { type: 'number', value: '2' },
49
+ { type: 'rparen' },
50
+ { type: 'operator', value: '*' },
51
+ { type: 'number', value: '3' },
52
+ ]),
53
+ new Set(),
54
+ );
55
+ expect(result.valid).toBe(true);
56
+ });
57
+ });
58
+
59
+ describe('when formula is invalid', () => {
60
+ test('reports unknown field', () => {
61
+ const result = subject(
62
+ f([{ type: 'field', path: 'unknown', fieldType: 'number' }]),
63
+ new Set(),
64
+ );
65
+ expect(result.valid).toBe(false);
66
+ expect(result.errors.some(e => e.includes('Unknown field'))).toBe(true);
67
+ });
68
+
69
+ test('uses pathToLabel for unknown field errors', () => {
70
+ const result = subject(
71
+ f([{ type: 'field', path: 'x', fieldType: 'number' }]),
72
+ new Set(),
73
+ undefined,
74
+ new Map([['x', 'My Field']]),
75
+ );
76
+ expect(result.errors.some(e => e.includes('My Field'))).toBe(true);
77
+ });
78
+
79
+ test('reports unbalanced opening parenthesis', () => {
80
+ const result = subject(
81
+ f([{ type: 'lparen' }, { type: 'number', value: '1' }]),
82
+ new Set(),
83
+ );
84
+ expect(result.errors).toContain('Unbalanced parentheses');
85
+ });
86
+
87
+ test('reports unbalanced closing parenthesis', () => {
88
+ const result = subject(
89
+ f([{ type: 'number', value: '1' }, { type: 'rparen' }]),
90
+ new Set(),
91
+ );
92
+ expect(result.errors).toContain('Unbalanced parentheses');
93
+ });
94
+
95
+ test('reports two adjacent operands as missing operator', () => {
96
+ const result = subject(
97
+ f([
98
+ { type: 'number', value: '1' },
99
+ { type: 'number', value: '2' },
100
+ ]),
101
+ new Set(),
102
+ );
103
+ expect(result.errors).toContain('Operator required between two fields or values');
104
+ });
105
+
106
+ test('reports formula starting with operator', () => {
107
+ const result = subject(
108
+ f([
109
+ { type: 'operator', value: '+' },
110
+ { type: 'number', value: '1' },
111
+ ]),
112
+ new Set(),
113
+ );
114
+ expect(
115
+ result.errors.some(e => e.includes('start with an operand')),
116
+ ).toBe(true);
117
+ });
118
+
119
+ test('reports formula ending with operator', () => {
120
+ const result = subject(
121
+ f([
122
+ { type: 'number', value: '1' },
123
+ { type: 'operator', value: '+' },
124
+ ]),
125
+ new Set(),
126
+ );
127
+ expect(
128
+ result.errors.some(e => e.includes('end with an operand')),
129
+ ).toBe(true);
130
+ });
131
+
132
+ test('reports invalid number value', () => {
133
+ const result = subject(f([{ type: 'number', value: 'abc' }]), new Set());
134
+ expect(result.errors.some(e => e.includes('Invalid number'))).toBe(true);
135
+ });
136
+ });
137
+
138
+ describe('with date field constraints', () => {
139
+ const dateFieldFormula = (operator: '*' | '/' | '+') =>
140
+ f([
141
+ { type: 'field', path: 'd', fieldType: 'date' },
142
+ { type: 'operator', value: operator },
143
+ { type: 'number', value: '1' },
144
+ ]);
145
+
146
+ test.each(['*', '/'] as const)('rejects %s operator with date fields', operator => {
147
+ const result = subject(dateFieldFormula(operator), new Set(['d']), new Set(['d']));
148
+ expect(
149
+ result.errors.some(e => e.includes('Multiply') && e.includes('divide')),
150
+ ).toBe(true);
151
+ });
152
+
153
+ test('allows + and - with date fields', () => {
154
+ const result = subject(dateFieldFormula('+'), new Set(['d']), new Set(['d']));
155
+ expect(result.valid).toBe(true);
156
+ });
157
+
158
+ test('rejects numbers larger than MAX_DATE_CALC_DAYS with date fields', () => {
159
+ const formula = f([
160
+ { type: 'field', path: 'd', fieldType: 'date' },
161
+ { type: 'operator', value: '+' },
162
+ { type: 'number', value: '5000' },
163
+ ]);
164
+ const result = subject(formula, new Set(['d']), new Set(['d']));
165
+ expect(result.errors.some(e => e.includes('Number of days cannot exceed'))).toBe(true);
166
+ });
167
+ });
168
+ });
@@ -32,6 +32,50 @@ export function tokensToExpression(formula: StructuredFormula | undefined): stri
32
32
  .trim();
33
33
  }
34
34
 
35
+ /**
36
+ * Convert StructuredFormula tokens to a human-readable display string.
37
+ *
38
+ * Field token label resolution order:
39
+ * 1. `pathToLabel[path]` — caller-provided override (fillable / data-model labels).
40
+ * 2. `formSnapshot.fieldName` — embedded form-field label carried on the token.
41
+ * 3. raw `path` — last-resort fallback.
42
+ *
43
+ * Use this for read-only UI surfaces where the formula is shown for context
44
+ * (overlays, previews) — NOT for round-tripping through `parseExpression`,
45
+ * which expects paths.
46
+ */
47
+ export function formulaToDisplayText(
48
+ formula: StructuredFormula | undefined,
49
+ pathToLabel?: Map<string, string>,
50
+ ): string {
51
+ if (!formula?.tokens?.length) {
52
+ return '';
53
+ }
54
+ return formula.tokens
55
+ .map(t => {
56
+ if (t.type === 'field') {
57
+ const overrideLabel = pathToLabel?.get(t.path);
58
+ if (overrideLabel) {
59
+ return overrideLabel;
60
+ }
61
+ if (t.formSnapshot?.fieldName) {
62
+ return t.formSnapshot.fieldName;
63
+ }
64
+ return t.path;
65
+ }
66
+ if (t.type === 'lparen') {
67
+ return '(';
68
+ }
69
+ if (t.type === 'rparen') {
70
+ return ')';
71
+ }
72
+ return t.value;
73
+ })
74
+ .join(' ')
75
+ .replace(/\s+/g, ' ')
76
+ .trim();
77
+ }
78
+
35
79
  /** Token from tokenizing an expression string (before resolving to FormulaToken) */
36
80
  export interface ExpressionPart {
37
81
  type: 'field' | 'number' | 'operator' | 'paren' | 'text';
@@ -0,0 +1,26 @@
1
+ import { ESignFieldType } from '../../../interface/types';
2
+ import { generateESignPath } from '../generate-e-sign-path';
3
+
4
+ describe('generateESignPath', () => {
5
+ const subject = (recipient: string, subType: ESignFieldType) =>
6
+ generateESignPath(recipient, subType);
7
+
8
+ test.each([
9
+ [ESignFieldType.signature, 'esign_signer1_signature'],
10
+ [ESignFieldType.initials, 'esign_signer1_initials'],
11
+ [ESignFieldType.dateSigned, 'esign_signer1_dateSigned'],
12
+ [ESignFieldType.fullName, 'esign_signer1_fullName'],
13
+ ])('returns esign_{recipient}_%s path', (subType, expected) => {
14
+ expect(subject('signer1', subType)).toBe(expected);
15
+ });
16
+
17
+ test('builds path with recipient containing special characters', () => {
18
+ expect(subject('recipient-1', ESignFieldType.signature)).toBe(
19
+ 'esign_recipient-1_signature',
20
+ );
21
+ });
22
+
23
+ test('builds path with empty recipient', () => {
24
+ expect(subject('', ESignFieldType.signature)).toBe('esign__signature');
25
+ });
26
+ });
@@ -0,0 +1,17 @@
1
+ import { generateFillablePath } from '../generate-fillable-path';
2
+
3
+ describe('generateFillablePath', () => {
4
+ const subject = (recipient: string, name: string) => generateFillablePath(recipient, name);
5
+
6
+ test('builds fillable_{recipient}_{name} path', () => {
7
+ expect(subject('signer1', 'fieldName')).toBe('fillable_signer1_fieldName');
8
+ });
9
+
10
+ test('builds path with empty recipient', () => {
11
+ expect(subject('', 'fieldName')).toBe('fillable__fieldName');
12
+ });
13
+
14
+ test('builds path with empty name', () => {
15
+ expect(subject('signer1', '')).toBe('fillable_signer1_');
16
+ });
17
+ });