@servicetitan/dte-pdf-editor 1.44.0 → 1.46.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 (50) hide show
  1. package/dist/components/field-config-panel/field-config-panel.d.ts.map +1 -1
  2. package/dist/components/field-config-panel/field-config-panel.js +4 -2
  3. package/dist/components/field-config-panel/field-config-panel.js.map +1 -1
  4. package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.d.ts.map +1 -1
  5. package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.js +3 -0
  6. package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.js.map +1 -1
  7. package/dist/components/pdf-view/pdf-view-fillable.d.ts +8 -0
  8. package/dist/components/pdf-view/pdf-view-fillable.d.ts.map +1 -1
  9. package/dist/components/pdf-view/pdf-view-fillable.js +17 -8
  10. package/dist/components/pdf-view/pdf-view-fillable.js.map +1 -1
  11. package/dist/interface/types.d.ts +1 -0
  12. package/dist/interface/types.d.ts.map +1 -1
  13. package/dist/interface/types.js.map +1 -1
  14. package/dist/utils/shared/date.utils.d.ts +1 -0
  15. package/dist/utils/shared/date.utils.d.ts.map +1 -1
  16. package/dist/utils/shared/date.utils.js +12 -0
  17. package/dist/utils/shared/date.utils.js.map +1 -1
  18. package/package.json +1 -1
  19. package/src/__tests__/field-types/date-fields.test.ts +212 -0
  20. package/src/__tests__/field-types/display-condition-fields.test.ts +340 -0
  21. package/src/__tests__/field-types/e-sign-fields.test.ts +97 -0
  22. package/src/__tests__/field-types/fillable-fields.test.ts +180 -0
  23. package/src/__tests__/field-types/form-fields.test.ts +193 -0
  24. package/src/__tests__/field-types/formula-fields.test.ts +245 -0
  25. package/src/__tests__/field-types/merge-tag-fields.test.ts +208 -0
  26. package/src/components/field-config-panel/field-config-panel.tsx +11 -0
  27. package/src/components/pdf-fields-overlay/pdf-overlay-field-fillable.tsx +10 -0
  28. package/src/components/pdf-view/pdf-view-fillable.tsx +61 -15
  29. package/src/interface/types.ts +1 -0
  30. package/src/styles/inline-editable.css +1 -0
  31. package/src/utils/conditions/__tests__/evaluate.utils.test.ts +163 -0
  32. package/src/utils/conditions/__tests__/schema-data-points.utils.test.ts +149 -0
  33. package/src/utils/data-model/__tests__/extract-fields.utils.test.ts +154 -0
  34. package/src/utils/data-model/__tests__/resolve-values.utils.test.ts +60 -0
  35. package/src/utils/field/__tests__/field-background-color.utils.test.ts +26 -0
  36. package/src/utils/field/__tests__/field-placeholder-text.utils.test.ts +46 -0
  37. package/src/utils/formula/__tests__/dom.utils.test.ts +33 -0
  38. package/src/utils/formula/__tests__/evaluate-formula.utils.test.ts +202 -0
  39. package/src/utils/formula/__tests__/expression.utils.test.ts +119 -0
  40. package/src/utils/formula/__tests__/form-fields.utils.test.ts +274 -0
  41. package/src/utils/formula/__tests__/format-calculated-result.utils.test.ts +105 -0
  42. package/src/utils/formula/__tests__/render-formula.utils.test.ts +43 -0
  43. package/src/utils/formula/__tests__/validate-formula.utils.test.ts +168 -0
  44. package/src/utils/path/__tests__/generate-e-sign-path.test.ts +26 -0
  45. package/src/utils/path/__tests__/generate-fillable-path.test.ts +17 -0
  46. package/src/utils/path/__tests__/parse-fillable-path.test.ts +25 -0
  47. package/src/utils/recipients/__tests__/map-colors.test.ts +40 -0
  48. package/src/utils/shared/__tests__/date.utils.test.ts +58 -0
  49. package/src/utils/shared/__tests__/number.utils.test.ts +48 -0
  50. package/src/utils/shared/date.utils.ts +16 -0
@@ -0,0 +1,193 @@
1
+ import {
2
+ FieldTypeEnum,
3
+ FormFieldInfo,
4
+ FormFieldsByFormIdI,
5
+ FormInfo,
6
+ StructuredFormula,
7
+ } from '../../interface/types';
8
+ import {
9
+ buildFormFieldKey,
10
+ evaluateFormula,
11
+ formFieldInfoToFieldTypeOption,
12
+ formFieldInfosToCalculationOptions,
13
+ formFieldToDisplayConditionDataPointOption,
14
+ normalizeFormFieldIdForPath,
15
+ parseFormFieldKey,
16
+ tryBuildFormFieldFormulaSnapshot,
17
+ } from '../../utils';
18
+
19
+ /**
20
+ * Scenario tests for form submission fields.
21
+ *
22
+ * Form fields belong to a referenced submission form and are addressed by
23
+ * the path `__submission_fields.{formId}.{normalizedFieldId}`. Field IDs
24
+ * contain hyphens that are stripped when building the path so it survives
25
+ * nunjucks templating. Legacy formats (`__submission_field` singular and
26
+ * `__form_{id}_{hex}`) must still parse for back-compat.
27
+ */
28
+
29
+ const form: FormInfo = { id: 7, name: 'Customer Intake' };
30
+
31
+ const formFields: FormFieldInfo[] = [
32
+ { id: 'score-uuid-1', header: 'Score', itemType: 'number' },
33
+ { id: 'when-uuid-2', header: 'When', itemType: 'date' },
34
+ { id: 'note-uuid-3', header: 'Note', itemType: 'text' },
35
+ ];
36
+
37
+ const formFieldsByFormId: FormFieldsByFormIdI = { 7: formFields };
38
+
39
+ describe('form field scenarios', () => {
40
+ describe('with key generation and parsing', () => {
41
+ test('normalizes hyphenated field IDs', () => {
42
+ expect(normalizeFormFieldIdForPath('abc-123-def')).toBe('abc123def');
43
+ });
44
+
45
+ test('builds __submission_fields path with normalized id', () => {
46
+ expect(buildFormFieldKey(form.id, 'score-uuid-1')).toBe(
47
+ '__submission_fields.7.scoreuuid1',
48
+ );
49
+ });
50
+
51
+ test.each([
52
+ [
53
+ 'canonical path',
54
+ '__submission_fields.7.scoreuuid1',
55
+ { formId: 7, fieldId: 'scoreuuid1' },
56
+ ],
57
+ [
58
+ 'legacy singular path (hyphens stripped)',
59
+ '__submission_field.7.score-uuid-1',
60
+ { formId: 7, fieldId: 'scoreuuid1' },
61
+ ],
62
+ [
63
+ 'legacy __form_ path',
64
+ '__form_7_abc123',
65
+ { formId: 7, fieldId: 'abc123' },
66
+ ],
67
+ ])('parses %s', (_, key, expected) => {
68
+ expect(parseFormFieldKey(key)).toEqual(expected);
69
+ });
70
+
71
+ test('returns null for non-form keys', () => {
72
+ expect(parseFormFieldKey('Customer.Name')).toBeNull();
73
+ });
74
+ });
75
+
76
+ describe('with calculation options', () => {
77
+ const subject = () =>
78
+ formFieldInfosToCalculationOptions(form.id, formFields, form.name);
79
+
80
+ test('includes only number and date fields', () => {
81
+ expect(subject().map(o => o.label)).toEqual(['Score', 'When']);
82
+ });
83
+
84
+ test('marks each calculation option with the forms field type', () => {
85
+ expect(subject().every(o => o.type === FieldTypeEnum.forms)).toBe(true);
86
+ });
87
+
88
+ test('attaches form snapshot to each calculation option', () => {
89
+ expect(subject()[0].formSnapshot).toEqual({
90
+ formId: 7,
91
+ fieldId: 'score-uuid-1',
92
+ formName: 'Customer Intake',
93
+ fieldName: 'Score',
94
+ fieldType: 'number',
95
+ });
96
+ });
97
+ });
98
+
99
+ describe('with sidebar field options', () => {
100
+ test('includes text fields without a fieldType', () => {
101
+ const option = formFieldInfoToFieldTypeOption(form.id, formFields[2], form.name);
102
+ expect(option.label).toBe('Note');
103
+ expect(option.fieldType).toBeUndefined();
104
+ });
105
+
106
+ test('includes calculation type for numeric fields', () => {
107
+ const option = formFieldInfoToFieldTypeOption(form.id, formFields[0], form.name);
108
+ expect(option.fieldType).toBe('number');
109
+ });
110
+ });
111
+
112
+ describe('with display-condition data points', () => {
113
+ test.each([
114
+ ['number', formFields[0], 'number'],
115
+ ['date', formFields[1], 'string'],
116
+ ['text', formFields[2], 'string'],
117
+ ] as const)(
118
+ 'maps %s field to display-condition fieldType %s',
119
+ (_, field, expectedType) => {
120
+ const option = formFieldToDisplayConditionDataPointOption(form, field);
121
+ expect(option.fieldType).toBe(expectedType);
122
+ },
123
+ );
124
+
125
+ test('uses field header as the display title', () => {
126
+ const option = formFieldToDisplayConditionDataPointOption(form, formFields[0]);
127
+ expect(option.title).toBe('Score');
128
+ });
129
+
130
+ test('includes form snapshot for safe rendering', () => {
131
+ const option = formFieldToDisplayConditionDataPointOption(form, formFields[0]);
132
+ expect(option.formSnapshot).toEqual({
133
+ formId: 7,
134
+ fieldId: 'score-uuid-1',
135
+ formName: 'Customer Intake',
136
+ fieldName: 'Score',
137
+ fieldType: 'number',
138
+ });
139
+ });
140
+ });
141
+
142
+ describe('with formula snapshots resolved from path', () => {
143
+ const subject = (path: string) =>
144
+ tryBuildFormFieldFormulaSnapshot(path, [form], formFieldsByFormId);
145
+
146
+ test('returns snapshot for known form field path', () => {
147
+ expect(subject('__submission_fields.7.scoreuuid1')).toEqual({
148
+ formId: 7,
149
+ fieldId: 'score-uuid-1',
150
+ formName: 'Customer Intake',
151
+ fieldName: 'Score',
152
+ fieldType: 'number',
153
+ });
154
+ });
155
+
156
+ test.each([
157
+ ['unrelated path', 'Customer.Name'],
158
+ ['unknown form', '__submission_fields.99.abc123'],
159
+ ['unknown field id', '__submission_fields.7.unknownid'],
160
+ ])('returns undefined for %s', (_, path) => {
161
+ expect(subject(path)).toBeUndefined();
162
+ });
163
+ });
164
+
165
+ describe('with form fields evaluated in a formula at runtime', () => {
166
+ const scoreKey = buildFormFieldKey(7, 'score-uuid-1');
167
+
168
+ test('reads numeric form field via __submission_fields path', () => {
169
+ const formula: StructuredFormula = {
170
+ tokens: [
171
+ { type: 'field', path: scoreKey, fieldType: 'number' },
172
+ { type: 'operator', value: '*' },
173
+ { type: 'number', value: '2' },
174
+ ],
175
+ };
176
+ const data = {
177
+ __submission_fields: { 7: { scoreuuid1: '21' } },
178
+ };
179
+ expect(evaluateFormula(formula, data)).toBe(42);
180
+ });
181
+
182
+ test('returns null when form submission data is missing', () => {
183
+ const formula: StructuredFormula = {
184
+ tokens: [
185
+ { type: 'field', path: scoreKey, fieldType: 'number' },
186
+ { type: 'operator', value: '+' },
187
+ { type: 'number', value: '1' },
188
+ ],
189
+ };
190
+ expect(evaluateFormula(formula, {})).toBeNull();
191
+ });
192
+ });
193
+ });
@@ -0,0 +1,245 @@
1
+ import {
2
+ CalculatedFormatForNumber,
3
+ FormFieldsByFormIdI,
4
+ FormInfo,
5
+ StructuredFormula,
6
+ } from '../../interface/types';
7
+ import {
8
+ buildFormFieldKey,
9
+ evaluateFormula,
10
+ formatCalculatedResult,
11
+ generateFillablePath,
12
+ parseExpression,
13
+ tokensToExpression,
14
+ validateFormula,
15
+ } from '../../utils';
16
+
17
+ /**
18
+ * Scenario tests for calculated (formula) fields.
19
+ *
20
+ * Formulas can reference three operand types — merge tags, fillable fields,
21
+ * form submission fields — and three numeric operations plus parentheses.
22
+ * These tests validate the full pipeline: parse expression -> structured
23
+ * formula -> validate -> evaluate at runtime -> format the result.
24
+ */
25
+
26
+ const fillablePath = generateFillablePath('signer1', 'TipAmount');
27
+
28
+ const forms: FormInfo[] = [{ id: 7, name: 'Inspection' }];
29
+ const formFieldsByFormId: FormFieldsByFormIdI = {
30
+ 7: [{ id: 'abc-123', header: 'Score', itemType: 'number' }],
31
+ };
32
+ const formScoreKey = buildFormFieldKey(7, 'abc-123');
33
+
34
+ const validPaths = new Set(['Job.Total', 'Job.Discount', fillablePath, formScoreKey]);
35
+
36
+ const baseNumberFormat: CalculatedFormatForNumber = {
37
+ resultType: 'number',
38
+ thousandsSeparator: true,
39
+ decimals: 2,
40
+ roundingMode: 'round',
41
+ decimalSeparatorEnabled: false,
42
+ decimalSeparator: '.',
43
+ prefixText: '$',
44
+ postfixText: '',
45
+ };
46
+
47
+ describe('formula (calculated) field scenarios', () => {
48
+ describe('with parsing an editor expression', () => {
49
+ const subject = (expression: string) =>
50
+ parseExpression(expression, validPaths, undefined, {
51
+ forms,
52
+ formFieldsByFormId,
53
+ });
54
+
55
+ test('produces structured tokens for a formula mixing merge tag and fillable', () => {
56
+ const tokens = subject('Job.Total - ' + fillablePath);
57
+ expect(tokens).toEqual([
58
+ { type: 'field', path: 'Job.Total', fieldType: 'number' },
59
+ { type: 'operator', value: '-' },
60
+ { type: 'field', path: fillablePath, fieldType: 'number' },
61
+ ]);
62
+ });
63
+
64
+ test('attaches form snapshot when token references a form submission field', () => {
65
+ const tokens = subject(formScoreKey);
66
+ expect(tokens).toHaveLength(1);
67
+ expect(tokens[0]).toEqual(
68
+ expect.objectContaining({
69
+ type: 'field',
70
+ path: formScoreKey,
71
+ formSnapshot: expect.objectContaining({
72
+ formId: 7,
73
+ formName: 'Inspection',
74
+ fieldName: 'Score',
75
+ }),
76
+ }),
77
+ );
78
+ });
79
+
80
+ test('drops unknown field references silently', () => {
81
+ const tokens = subject('Job.Total + Unknown.Field');
82
+ expect(tokens.filter(t => t.type === 'field').map(t => t.type)).toEqual(['field']);
83
+ });
84
+
85
+ test('round-trips through tokensToExpression', () => {
86
+ const tokens = subject('( Job.Total - Job.Discount ) * 2');
87
+ const formula: StructuredFormula = { tokens };
88
+ expect(tokensToExpression(formula)).toBe('( Job.Total - Job.Discount ) * 2');
89
+ });
90
+ });
91
+
92
+ describe('with validation', () => {
93
+ test('accepts a balanced formula referencing known paths', () => {
94
+ const formula: StructuredFormula = {
95
+ tokens: parseExpression('Job.Total - Job.Discount', validPaths),
96
+ };
97
+ const result = validateFormula(formula, validPaths);
98
+ expect(result.valid).toBe(true);
99
+ expect(result.errors).toEqual([]);
100
+ });
101
+
102
+ test('rejects a formula with unbalanced parentheses', () => {
103
+ const formula: StructuredFormula = {
104
+ tokens: [
105
+ { type: 'lparen' },
106
+ { type: 'number', value: '1' },
107
+ ],
108
+ };
109
+ expect(validateFormula(formula, validPaths).errors).toContain(
110
+ 'Unbalanced parentheses',
111
+ );
112
+ });
113
+
114
+ test('rejects a formula referencing an unknown field', () => {
115
+ const formula: StructuredFormula = {
116
+ tokens: [
117
+ { type: 'field', path: 'Unknown.Field', fieldType: 'number' },
118
+ { type: 'operator', value: '+' },
119
+ { type: 'number', value: '1' },
120
+ ],
121
+ };
122
+ const result = validateFormula(formula, validPaths);
123
+ expect(result.valid).toBe(false);
124
+ expect(result.errors.some(e => e.includes('Unknown field'))).toBe(true);
125
+ });
126
+
127
+ test('rejects multiply/divide with date fields', () => {
128
+ const formula: StructuredFormula = {
129
+ tokens: [
130
+ { type: 'field', path: 'd', fieldType: 'date' },
131
+ { type: 'operator', value: '*' },
132
+ { type: 'number', value: '2' },
133
+ ],
134
+ };
135
+ const result = validateFormula(formula, new Set(['d']), new Set(['d']));
136
+ expect(result.errors.some(e => e.includes('Multiply'))).toBe(true);
137
+ });
138
+ });
139
+
140
+ describe('with evaluation across operand sources', () => {
141
+ test('evaluates merge tag + fillable + form field operands together', () => {
142
+ const formula: StructuredFormula = {
143
+ tokens: [
144
+ { type: 'field', path: 'Job.Total', fieldType: 'number' },
145
+ { type: 'operator', value: '+' },
146
+ { type: 'field', path: fillablePath, fieldType: 'number' },
147
+ { type: 'operator', value: '+' },
148
+ { type: 'field', path: formScoreKey, fieldType: 'number' },
149
+ ],
150
+ };
151
+ // formScoreKey resolves through __submission_fields.7.abc123 nested path
152
+ const data = {
153
+ Job: { Total: 100 },
154
+ [fillablePath]: '20',
155
+ __submission_fields: { 7: { abc123: '5' } },
156
+ };
157
+ expect(evaluateFormula(formula, data)).toBe(125);
158
+ });
159
+
160
+ test('honors parentheses and operator precedence', () => {
161
+ const formula: StructuredFormula = {
162
+ tokens: [
163
+ { type: 'lparen' },
164
+ { type: 'field', path: 'Job.Total', fieldType: 'number' },
165
+ { type: 'operator', value: '-' },
166
+ { type: 'field', path: 'Job.Discount', fieldType: 'number' },
167
+ { type: 'rparen' },
168
+ { type: 'operator', value: '*' },
169
+ { type: 'number', value: '0.1' },
170
+ ],
171
+ };
172
+ expect(evaluateFormula(formula, { Job: { Total: 200, Discount: 50 } })).toBeCloseTo(
173
+ 15,
174
+ 5,
175
+ );
176
+ });
177
+
178
+ test('returns null when any field value is missing', () => {
179
+ const formula: StructuredFormula = {
180
+ tokens: [
181
+ { type: 'field', path: 'Job.Total', fieldType: 'number' },
182
+ { type: 'operator', value: '+' },
183
+ { type: 'field', path: 'Job.Discount', fieldType: 'number' },
184
+ ],
185
+ };
186
+ expect(evaluateFormula(formula, { Job: { Total: 100 } })).toBeNull();
187
+ });
188
+
189
+ test('returns 0 when dividing by zero rather than throwing', () => {
190
+ const formula: StructuredFormula = {
191
+ tokens: [
192
+ { type: 'number', value: '10' },
193
+ { type: 'operator', value: '/' },
194
+ { type: 'number', value: '0' },
195
+ ],
196
+ };
197
+ expect(evaluateFormula(formula, {})).toBe(0);
198
+ });
199
+ });
200
+
201
+ describe('with result formatting', () => {
202
+ test('applies currency prefix with thousands and decimals', () => {
203
+ const formatted = formatCalculatedResult(1234.5, {
204
+ ...baseNumberFormat,
205
+ resultType: 'currency',
206
+ });
207
+ expect(formatted).toBe('$1,234.50');
208
+ });
209
+
210
+ test('applies percent postfix when configured', () => {
211
+ const formatted = formatCalculatedResult(0.42, {
212
+ ...baseNumberFormat,
213
+ prefixText: '',
214
+ postfixText: '%',
215
+ decimals: 1,
216
+ thousandsSeparator: false,
217
+ });
218
+ expect(formatted).toBe('0.4%');
219
+ });
220
+
221
+ test('returns empty string for non-finite formula result', () => {
222
+ expect(formatCalculatedResult(NaN, baseNumberFormat)).toBe('');
223
+ });
224
+ });
225
+
226
+ describe('with full pipeline', () => {
227
+ test('parses, validates, evaluates, and formats a customer-discount formula', () => {
228
+ const expression = '( Job.Total - Job.Discount ) * 0.1';
229
+ const tokens = parseExpression(expression, validPaths);
230
+ const formula: StructuredFormula = { tokens };
231
+
232
+ const validation = validateFormula(formula, validPaths);
233
+ expect(validation.valid).toBe(true);
234
+
235
+ const value = evaluateFormula(formula, { Job: { Total: 1500, Discount: 200 } });
236
+ expect(value).toBe(130);
237
+
238
+ const formatted = formatCalculatedResult(value as number, {
239
+ ...baseNumberFormat,
240
+ resultType: 'currency',
241
+ });
242
+ expect(formatted).toBe('$130.00');
243
+ });
244
+ });
245
+ });
@@ -0,0 +1,208 @@
1
+ import {
2
+ FieldTypeEnum,
3
+ SchemaObject,
4
+ StructuredFormula,
5
+ } from '../../interface/types';
6
+ import {
7
+ evaluateFormula,
8
+ extractGroupedFieldsFromDataModel,
9
+ getDataPointOptions,
10
+ getSchemaDataPointOptions,
11
+ resolvePdfDataValues,
12
+ } from '../../utils';
13
+
14
+ /**
15
+ * Scenario tests for merge tag (data model) fields.
16
+ *
17
+ * Merge tags come from a JSON schema describing the data model. They appear
18
+ * in the sidebar (grouped), in display-condition drop-downs (only fields
19
+ * flagged useInConditionals/useInCalculatedFields), and resolve to runtime
20
+ * values via dotted paths.
21
+ */
22
+
23
+ const customerSchema: SchemaObject = {
24
+ type: 'object',
25
+ properties: {
26
+ FirstName: {
27
+ type: 'string',
28
+ title: 'First Name',
29
+ options: { useInConditionals: true },
30
+ },
31
+ LastName: { type: 'string', title: 'Last Name' },
32
+ Job: {
33
+ type: 'object',
34
+ title: 'Job',
35
+ properties: {
36
+ Total: {
37
+ type: 'number',
38
+ title: 'Total',
39
+ options: { useInCalculatedFields: true, useInConditionals: true },
40
+ },
41
+ Discount: {
42
+ type: 'number',
43
+ title: 'Discount',
44
+ options: { useInCalculatedFields: true },
45
+ },
46
+ Notes: { type: 'string', title: 'Notes' },
47
+ Technicians: {
48
+ type: 'array',
49
+ items: {
50
+ type: 'object',
51
+ properties: { Name: { type: 'string' } },
52
+ },
53
+ },
54
+ },
55
+ },
56
+ },
57
+ };
58
+
59
+ const customerData = {
60
+ FirstName: 'Jane',
61
+ LastName: 'Doe',
62
+ Job: {
63
+ Total: 150,
64
+ Discount: 20,
65
+ Notes: 'Standard service',
66
+ Price: { Amount: 100, Currency: 'USD' },
67
+ Custom: { Value: 'wrapped value' },
68
+ },
69
+ };
70
+
71
+ describe('merge tag (data model) field scenarios', () => {
72
+ describe('with sidebar field extraction', () => {
73
+ const subject = () => extractGroupedFieldsFromDataModel(customerSchema);
74
+
75
+ test('groups top-level simple fields under "Fields"', () => {
76
+ const groups = subject();
77
+ const fieldsGroup = groups.find(g => g.groupName === 'Fields');
78
+ expect(fieldsGroup?.fields.map(f => f.path)).toEqual(['FirstName', 'LastName']);
79
+ });
80
+
81
+ test('groups nested object fields under the object title', () => {
82
+ const groups = subject();
83
+ const jobGroup = groups.find(g => g.groupName === 'Job');
84
+ expect(jobGroup?.fields.map(f => f.path).sort()).toEqual([
85
+ 'Job.Discount',
86
+ 'Job.Notes',
87
+ 'Job.Total',
88
+ ]);
89
+ });
90
+
91
+ test('excludes array properties', () => {
92
+ const groups = subject();
93
+ const jobGroup = groups.find(g => g.groupName === 'Job');
94
+ expect(jobGroup?.fields.some(f => f.path?.includes('Technicians'))).toBe(false);
95
+ });
96
+
97
+ test('marks numeric fields with fieldType number', () => {
98
+ const groups = subject();
99
+ const jobGroup = groups.find(g => g.groupName === 'Job');
100
+ const total = jobGroup?.fields.find(f => f.path === 'Job.Total');
101
+ expect(total?.fieldType).toBe('number');
102
+ expect(total?.type).toBe(FieldTypeEnum.dataModel);
103
+ });
104
+ });
105
+
106
+ describe('with calculated-only field extraction', () => {
107
+ test('returns only fields flagged useInCalculatedFields', () => {
108
+ const groups = extractGroupedFieldsFromDataModel(customerSchema, {
109
+ onlyUseInCalculatedFields: true,
110
+ });
111
+ const allPaths = groups.flatMap(g => g.fields.map(f => f.path)).sort();
112
+ expect(allPaths).toEqual(['Job.Discount', 'Job.Total']);
113
+ });
114
+ });
115
+
116
+ describe('with display-condition options', () => {
117
+ test('exposes fields flagged useInConditionals or useInCalculatedFields', () => {
118
+ const options = getSchemaDataPointOptions(customerSchema);
119
+ expect(options.map(o => o.fullKey).sort()).toEqual([
120
+ 'FirstName',
121
+ 'Job.Discount',
122
+ 'Job.Total',
123
+ ]);
124
+ });
125
+
126
+ test('omits fields without either flag', () => {
127
+ const options = getSchemaDataPointOptions(customerSchema);
128
+ expect(options.some(o => o.fullKey === 'Job.Notes')).toBe(false);
129
+ expect(options.some(o => o.fullKey === 'LastName')).toBe(false);
130
+ });
131
+
132
+ test('uses dash-joined titles for nested fields', () => {
133
+ const options = getSchemaDataPointOptions(customerSchema);
134
+ const total = options.find(o => o.fullKey === 'Job.Total');
135
+ expect(total?.title).toBe('Job - Total');
136
+ });
137
+
138
+ test('combines schema and document field options sorted alphabetically', () => {
139
+ const fillableField = {
140
+ id: 'f1',
141
+ type: FieldTypeEnum.fillable,
142
+ subType: 'text' as const,
143
+ x: 0,
144
+ y: 0,
145
+ page: 1,
146
+ label: 'Custom Note',
147
+ width: 100,
148
+ height: 25,
149
+ path: 'fillable_signer1_CustomNote',
150
+ };
151
+ const options = getDataPointOptions(customerSchema, [fillableField]);
152
+ expect(options.map(o => o.title)).toEqual([
153
+ 'Custom Note',
154
+ 'First Name',
155
+ 'Job - Discount',
156
+ 'Job - Total',
157
+ ]);
158
+ });
159
+ });
160
+
161
+ describe('with value resolution at runtime', () => {
162
+ const subject = (path: string) => resolvePdfDataValues(customerData, path);
163
+
164
+ test('resolves a top-level string property', () => {
165
+ expect(subject('FirstName')).toBe('Jane');
166
+ });
167
+
168
+ test('resolves a nested numeric property as string', () => {
169
+ expect(subject('Job.Total')).toBe('150');
170
+ });
171
+
172
+ test('formats Amount/Currency objects as "{Currency} {Amount}"', () => {
173
+ expect(subject('Job.Price')).toBe('USD 100');
174
+ });
175
+
176
+ test('unwraps Value-wrapped objects', () => {
177
+ expect(subject('Job.Custom')).toBe('wrapped value');
178
+ });
179
+
180
+ test('returns empty string for missing path', () => {
181
+ expect(subject('Job.NonExistent')).toBe('');
182
+ });
183
+ });
184
+
185
+ describe('with merge tags used in a formula', () => {
186
+ test('evaluates Job.Total - Job.Discount using runtime data', () => {
187
+ const formula: StructuredFormula = {
188
+ tokens: [
189
+ { type: 'field', path: 'Job.Total', fieldType: 'number' },
190
+ { type: 'operator', value: '-' },
191
+ { type: 'field', path: 'Job.Discount', fieldType: 'number' },
192
+ ],
193
+ };
194
+ expect(evaluateFormula(formula, customerData)).toBe(130);
195
+ });
196
+
197
+ test('returns null when merge tag value is missing', () => {
198
+ const formula: StructuredFormula = {
199
+ tokens: [
200
+ { type: 'field', path: 'Job.NonExistent', fieldType: 'number' },
201
+ { type: 'operator', value: '+' },
202
+ { type: 'number', value: '10' },
203
+ ],
204
+ };
205
+ expect(evaluateFormula(formula, customerData)).toBeNull();
206
+ });
207
+ });
208
+ });
@@ -162,6 +162,17 @@ export const FieldConfigPanel: FC<FieldConfigPanelProps> = ({
162
162
  }
163
163
  />
164
164
  )}
165
+ {field.type === FieldTypeEnum.fillable && field.subType === 'text' && (
166
+ <Checkbox
167
+ label="Multi-line"
168
+ checked={field.multiline ?? false}
169
+ onChange={() =>
170
+ onFieldConfigChange({
171
+ multiline: !field.multiline,
172
+ })
173
+ }
174
+ />
175
+ )}
165
176
  {field.type === FieldTypeEnum.calculated && (
166
177
  <FormulaGenerator
167
178
  dataModel={dataModel}
@@ -56,6 +56,16 @@ export const PdfOverlayFieldFillable: FC<PdfOverlayFieldFillableProps> = ({
56
56
  );
57
57
 
58
58
  default:
59
+ if (field.multiline) {
60
+ return (
61
+ <textarea
62
+ readOnly
63
+ placeholder={placeholderText}
64
+ className="dte-pdf-field-fillable"
65
+ style={{ resize: 'none' }}
66
+ />
67
+ );
68
+ }
59
69
  return (
60
70
  <input
61
71
  type="text"