@servicetitan/dte-pdf-editor 1.45.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 (45) 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 +14 -6
  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/package.json +1 -1
  15. package/src/__tests__/field-types/date-fields.test.ts +212 -0
  16. package/src/__tests__/field-types/display-condition-fields.test.ts +340 -0
  17. package/src/__tests__/field-types/e-sign-fields.test.ts +97 -0
  18. package/src/__tests__/field-types/fillable-fields.test.ts +180 -0
  19. package/src/__tests__/field-types/form-fields.test.ts +193 -0
  20. package/src/__tests__/field-types/formula-fields.test.ts +245 -0
  21. package/src/__tests__/field-types/merge-tag-fields.test.ts +208 -0
  22. package/src/components/field-config-panel/field-config-panel.tsx +11 -0
  23. package/src/components/pdf-fields-overlay/pdf-overlay-field-fillable.tsx +10 -0
  24. package/src/components/pdf-view/pdf-view-fillable.tsx +58 -13
  25. package/src/interface/types.ts +1 -0
  26. package/src/styles/inline-editable.css +1 -0
  27. package/src/utils/conditions/__tests__/evaluate.utils.test.ts +163 -0
  28. package/src/utils/conditions/__tests__/schema-data-points.utils.test.ts +149 -0
  29. package/src/utils/data-model/__tests__/extract-fields.utils.test.ts +154 -0
  30. package/src/utils/data-model/__tests__/resolve-values.utils.test.ts +60 -0
  31. package/src/utils/field/__tests__/field-background-color.utils.test.ts +26 -0
  32. package/src/utils/field/__tests__/field-placeholder-text.utils.test.ts +46 -0
  33. package/src/utils/formula/__tests__/dom.utils.test.ts +33 -0
  34. package/src/utils/formula/__tests__/evaluate-formula.utils.test.ts +202 -0
  35. package/src/utils/formula/__tests__/expression.utils.test.ts +119 -0
  36. package/src/utils/formula/__tests__/form-fields.utils.test.ts +274 -0
  37. package/src/utils/formula/__tests__/format-calculated-result.utils.test.ts +105 -0
  38. package/src/utils/formula/__tests__/render-formula.utils.test.ts +43 -0
  39. package/src/utils/formula/__tests__/validate-formula.utils.test.ts +168 -0
  40. package/src/utils/path/__tests__/generate-e-sign-path.test.ts +26 -0
  41. package/src/utils/path/__tests__/generate-fillable-path.test.ts +17 -0
  42. package/src/utils/path/__tests__/parse-fillable-path.test.ts +25 -0
  43. package/src/utils/recipients/__tests__/map-colors.test.ts +40 -0
  44. package/src/utils/shared/__tests__/date.utils.test.ts +58 -0
  45. package/src/utils/shared/__tests__/number.utils.test.ts +48 -0
@@ -0,0 +1,46 @@
1
+ import { FieldTypeEnum, PdfField } from '../../../interface/types';
2
+ import { getFieldPlaceholderText } from '../field-placeholder-text.utils';
3
+
4
+ const buildField = (overrides: Partial<PdfField> = {}): PdfField => ({
5
+ id: 'field-1',
6
+ type: FieldTypeEnum.fillable,
7
+ x: 0,
8
+ y: 0,
9
+ page: 1,
10
+ label: 'Field Label',
11
+ width: 100,
12
+ height: 25,
13
+ ...overrides,
14
+ });
15
+
16
+ describe('getFieldPlaceholderText', () => {
17
+ const subject = (field: PdfField, prefix?: string) => getFieldPlaceholderText(field, prefix);
18
+
19
+ test('returns label when no prefix and not required', () => {
20
+ expect(subject(buildField())).toBe('Field Label');
21
+ });
22
+
23
+ test('appends asterisk when field is required', () => {
24
+ expect(subject(buildField({ required: true }))).toBe('Field Label *');
25
+ });
26
+
27
+ test('prepends prefix when provided', () => {
28
+ expect(subject(buildField(), 'Enter')).toBe('Enter Field Label');
29
+ });
30
+
31
+ test('joins prefix, label, and required asterisk in order', () => {
32
+ expect(subject(buildField({ required: true }), 'Enter')).toBe('Enter Field Label *');
33
+ });
34
+
35
+ test('skips empty label', () => {
36
+ expect(subject(buildField({ label: '' }), 'Enter')).toBe('Enter');
37
+ });
38
+
39
+ test('returns required asterisk only when label is empty and required', () => {
40
+ expect(subject(buildField({ label: '', required: true }))).toBe('*');
41
+ });
42
+
43
+ test('returns empty string when label is empty, not required, and no prefix', () => {
44
+ expect(subject(buildField({ label: '' }))).toBe('');
45
+ });
46
+ });
@@ -0,0 +1,33 @@
1
+ import { escapeHtml } from '../dom.utils';
2
+
3
+ describe('escapeHtml', () => {
4
+ const subject = (value: string) => escapeHtml(value);
5
+
6
+ test.each([
7
+ ['<', '&lt;'],
8
+ ['>', '&gt;'],
9
+ ['&', '&amp;'],
10
+ ['"', '&quot;'],
11
+ ["'", '&#039;'],
12
+ ])('escapes %s to %s', (value, expected) => {
13
+ expect(subject(value)).toBe(expected);
14
+ });
15
+
16
+ test('escapes a typical HTML snippet', () => {
17
+ expect(subject('<div class="x">A & B</div>')).toBe(
18
+ '&lt;div class=&quot;x&quot;&gt;A &amp; B&lt;/div&gt;',
19
+ );
20
+ });
21
+
22
+ test('returns plain string unchanged', () => {
23
+ expect(subject('Hello world')).toBe('Hello world');
24
+ });
25
+
26
+ test('returns empty string for empty input', () => {
27
+ expect(subject('')).toBe('');
28
+ });
29
+
30
+ test('escapes ampersand first to avoid double encoding', () => {
31
+ expect(subject('&lt;')).toBe('&amp;lt;');
32
+ });
33
+ });
@@ -0,0 +1,202 @@
1
+ import { DataModelValues, StructuredFormula } from '../../../interface/types';
2
+ import { evaluateFormula, valueToNumber } from '../evaluate-formula.utils';
3
+
4
+ const f = (tokens: StructuredFormula['tokens']): StructuredFormula => ({ tokens });
5
+
6
+ describe('valueToNumber', () => {
7
+ const subject = (raw: unknown) => valueToNumber(raw);
8
+
9
+ test.each([
10
+ ['null', null, 0],
11
+ ['undefined', undefined, 0],
12
+ ['empty string', '', 0],
13
+ ['whitespace', ' ', 0],
14
+ ['NaN', NaN, 0],
15
+ ['non-numeric string', 'abc', 0],
16
+ ['boolean', true, 0],
17
+ ])('returns 0 for %s', (_, raw, expected) => {
18
+ expect(subject(raw)).toBe(expected);
19
+ });
20
+
21
+ test.each([
22
+ ['integer', 42, 42],
23
+ ['negative integer', -5, -5],
24
+ ['float', 3.14, 3.14],
25
+ ['integer string', '42', 42],
26
+ ['float string', '3.14', 3.14],
27
+ ['string with whitespace', ' 42 ', 42],
28
+ ])('returns numeric value for %s', (_, raw, expected) => {
29
+ expect(subject(raw)).toBe(expected);
30
+ });
31
+ });
32
+
33
+ describe('evaluateFormula', () => {
34
+ const subject = (
35
+ formula: StructuredFormula | undefined | null,
36
+ data: DataModelValues | undefined,
37
+ holidays?: string[],
38
+ ) => evaluateFormula(formula, data, holidays);
39
+
40
+ describe('when formula is empty', () => {
41
+ test.each([
42
+ ['undefined', undefined],
43
+ ['null', null],
44
+ ['empty tokens', f([])],
45
+ ])('returns null for %s', (_, formula) => {
46
+ expect(subject(formula, {})).toBeNull();
47
+ });
48
+ });
49
+
50
+ describe('with numeric arithmetic', () => {
51
+ test.each([
52
+ [
53
+ 'addition',
54
+ f([
55
+ { type: 'number', value: '1' },
56
+ { type: 'operator', value: '+' },
57
+ { type: 'number', value: '2' },
58
+ ]),
59
+ 3,
60
+ ],
61
+ [
62
+ 'subtraction',
63
+ f([
64
+ { type: 'number', value: '10' },
65
+ { type: 'operator', value: '-' },
66
+ { type: 'number', value: '3' },
67
+ ]),
68
+ 7,
69
+ ],
70
+ [
71
+ 'multiplication',
72
+ f([
73
+ { type: 'number', value: '4' },
74
+ { type: 'operator', value: '*' },
75
+ { type: 'number', value: '5' },
76
+ ]),
77
+ 20,
78
+ ],
79
+ [
80
+ 'division',
81
+ f([
82
+ { type: 'number', value: '20' },
83
+ { type: 'operator', value: '/' },
84
+ { type: 'number', value: '4' },
85
+ ]),
86
+ 5,
87
+ ],
88
+ ])('evaluates %s', (_, formula, expected) => {
89
+ expect(subject(formula, {})).toBe(expected);
90
+ });
91
+
92
+ test('returns 0 for division by zero', () => {
93
+ const formula = f([
94
+ { type: 'number', value: '10' },
95
+ { type: 'operator', value: '/' },
96
+ { type: 'number', value: '0' },
97
+ ]);
98
+ expect(subject(formula, {})).toBe(0);
99
+ });
100
+
101
+ test('honors operator precedence (multiply before add)', () => {
102
+ const formula = f([
103
+ { type: 'number', value: '1' },
104
+ { type: 'operator', value: '+' },
105
+ { type: 'number', value: '2' },
106
+ { type: 'operator', value: '*' },
107
+ { type: 'number', value: '3' },
108
+ ]);
109
+ expect(subject(formula, {})).toBe(7);
110
+ });
111
+
112
+ test('respects parentheses', () => {
113
+ const formula = f([
114
+ { type: 'lparen' },
115
+ { type: 'number', value: '1' },
116
+ { type: 'operator', value: '+' },
117
+ { type: 'number', value: '2' },
118
+ { type: 'rparen' },
119
+ { type: 'operator', value: '*' },
120
+ { type: 'number', value: '3' },
121
+ ]);
122
+ expect(subject(formula, {})).toBe(9);
123
+ });
124
+ });
125
+
126
+ describe('with field references', () => {
127
+ test('resolves field values from data', () => {
128
+ const formula = f([
129
+ { type: 'field', path: 'a', fieldType: 'number' },
130
+ { type: 'operator', value: '+' },
131
+ { type: 'field', path: 'b', fieldType: 'number' },
132
+ ]);
133
+ expect(subject(formula, { a: 5, b: 7 })).toBe(12);
134
+ });
135
+
136
+ test('returns null when any field value is missing', () => {
137
+ const formula = f([
138
+ { type: 'field', path: 'a', fieldType: 'number' },
139
+ { type: 'operator', value: '+' },
140
+ { type: 'field', path: 'b', fieldType: 'number' },
141
+ ]);
142
+ expect(subject(formula, { a: 5 })).toBeNull();
143
+ });
144
+
145
+ test('returns null when a field value is not numeric', () => {
146
+ const formula = f([
147
+ { type: 'field', path: 'a', fieldType: 'number' },
148
+ { type: 'operator', value: '+' },
149
+ { type: 'number', value: '1' },
150
+ ]);
151
+ expect(subject(formula, { a: 'not a number' })).toBeNull();
152
+ });
153
+ });
154
+
155
+ describe('with date fields', () => {
156
+ test('adds business days to a date field (skipping weekend)', () => {
157
+ // Friday 2024-01-05 + 1 business day = Monday 2024-01-08
158
+ const formula = f([
159
+ { type: 'field', path: 'd', fieldType: 'date' },
160
+ { type: 'operator', value: '+' },
161
+ { type: 'number', value: '1' },
162
+ ]);
163
+ const result = subject(formula, { d: '2024-01-05' });
164
+ expect(result).not.toBeNull();
165
+ const date = new Date(result as number);
166
+ expect(date.getUTCFullYear()).toBe(2024);
167
+ expect(date.getUTCMonth()).toBe(0);
168
+ expect(date.getUTCDate()).toBe(8);
169
+ });
170
+
171
+ test('counts business days when subtracting later date from earlier date', () => {
172
+ // start - end traverses Mon 2024-01-01 → Fri 2024-01-05 = 4 business days
173
+ const formula = f([
174
+ { type: 'field', path: 'start', fieldType: 'date' },
175
+ { type: 'operator', value: '-' },
176
+ { type: 'field', path: 'end', fieldType: 'date' },
177
+ ]);
178
+ expect(subject(formula, { start: '2024-01-01', end: '2024-01-05' })).toBe(4);
179
+ });
180
+
181
+ test('skips holidays when adding business days', () => {
182
+ // Monday 2024-01-08 + 1 day skips holiday 2024-01-09 = Wednesday 2024-01-10
183
+ const formula = f([
184
+ { type: 'field', path: 'd', fieldType: 'date' },
185
+ { type: 'operator', value: '+' },
186
+ { type: 'number', value: '1' },
187
+ ]);
188
+ const result = subject(formula, { d: '2024-01-08' }, ['2024-01-09']);
189
+ expect(result).not.toBeNull();
190
+ expect(new Date(result as number).getUTCDate()).toBe(10);
191
+ });
192
+
193
+ test('returns null when date field has invalid value', () => {
194
+ const formula = f([
195
+ { type: 'field', path: 'd', fieldType: 'date' },
196
+ { type: 'operator', value: '+' },
197
+ { type: 'number', value: '1' },
198
+ ]);
199
+ expect(subject(formula, { d: 'not-a-date' })).toBeNull();
200
+ });
201
+ });
202
+ });
@@ -0,0 +1,119 @@
1
+ import { StructuredFormula } from '../../../interface/types';
2
+ import {
3
+ normalizeMergeTags,
4
+ parseExpression,
5
+ tokenizeExpression,
6
+ tokensToExpression,
7
+ } from '../expression.utils';
8
+
9
+ describe('tokensToExpression', () => {
10
+ const subject = (formula: StructuredFormula | undefined) => tokensToExpression(formula);
11
+
12
+ test('returns empty string when formula is undefined', () => {
13
+ expect(subject(undefined)).toBe('');
14
+ });
15
+
16
+ test('returns empty string when tokens array is empty', () => {
17
+ expect(subject({ tokens: [] })).toBe('');
18
+ });
19
+
20
+ test('serializes a mix of token types separated by spaces', () => {
21
+ const formula: StructuredFormula = {
22
+ tokens: [
23
+ { type: 'lparen' },
24
+ { type: 'field', path: 'a.b', fieldType: 'number' },
25
+ { type: 'operator', value: '+' },
26
+ { type: 'number', value: '5' },
27
+ { type: 'rparen' },
28
+ { type: 'operator', value: '*' },
29
+ { type: 'number', value: '2' },
30
+ ],
31
+ };
32
+ expect(subject(formula)).toBe('( a.b + 5 ) * 2');
33
+ });
34
+ });
35
+
36
+ describe('tokenizeExpression', () => {
37
+ const subject = (expression: string) => tokenizeExpression(expression);
38
+
39
+ test('returns empty array for empty input', () => {
40
+ expect(subject('')).toEqual([]);
41
+ });
42
+
43
+ test('tokenizes identifiers with dots and hyphens as fields', () => {
44
+ expect(subject('user.name-id')).toEqual([{ type: 'field', value: 'user.name-id' }]);
45
+ });
46
+
47
+ test('tokenizes numbers including decimals', () => {
48
+ expect(subject('3.14 42')).toEqual([
49
+ { type: 'number', value: '3.14' },
50
+ { type: 'text', value: ' ' },
51
+ { type: 'number', value: '42' },
52
+ ]);
53
+ });
54
+
55
+ test('tokenizes operators and parens', () => {
56
+ const result = subject('(a + b)');
57
+ expect(result.filter(t => t.type === 'paren').map(t => t.value)).toEqual(['(', ')']);
58
+ expect(result.filter(t => t.type === 'operator').map(t => t.value)).toEqual(['+']);
59
+ });
60
+ });
61
+
62
+ describe('parseExpression', () => {
63
+ const subject = (
64
+ expression: string,
65
+ validPaths: Set<string>,
66
+ knownDateFields?: Set<string>,
67
+ ) => parseExpression(expression, validPaths, knownDateFields);
68
+
69
+ test('drops unknown field identifiers', () => {
70
+ const validPaths = new Set(['known.path']);
71
+ const tokens = subject('known.path + unknown + 1', validPaths);
72
+ expect(tokens).toEqual([
73
+ { type: 'field', path: 'known.path', fieldType: 'number' },
74
+ { type: 'operator', value: '+' },
75
+ { type: 'operator', value: '+' },
76
+ { type: 'number', value: '1' },
77
+ ]);
78
+ });
79
+
80
+ test('marks known date fields with fieldType date', () => {
81
+ const tokens = subject(
82
+ 'a.date + 1',
83
+ new Set(['a.date']),
84
+ new Set(['a.date']),
85
+ );
86
+ expect(tokens[0]).toEqual({ type: 'field', path: 'a.date', fieldType: 'date' });
87
+ });
88
+
89
+ test('parses parentheses to lparen/rparen tokens', () => {
90
+ const tokens = subject('( 1 + 2 )', new Set());
91
+ expect(tokens.map(t => t.type)).toEqual([
92
+ 'lparen',
93
+ 'number',
94
+ 'operator',
95
+ 'number',
96
+ 'rparen',
97
+ ]);
98
+ });
99
+ });
100
+
101
+ describe('normalizeMergeTags', () => {
102
+ const subject = (expression: string, validPaths: Set<string>) =>
103
+ normalizeMergeTags(expression, validPaths);
104
+
105
+ test('returns input unchanged when validPaths is empty', () => {
106
+ expect(subject('a b', new Set())).toEqual({ normalized: 'a b', removed: null });
107
+ });
108
+
109
+ test('returns input unchanged when no adjacent merge tags exist', () => {
110
+ const result = subject('a + b', new Set(['a', 'b']));
111
+ expect(result).toEqual({ normalized: 'a + b', removed: null });
112
+ });
113
+
114
+ test('removes the left tag when two valid tags are adjacent without separator', () => {
115
+ const result = subject('ab', new Set(['a', 'b']));
116
+ expect(result.normalized).toBe('b');
117
+ expect(result.removed).toEqual({ start: 0, end: 1 });
118
+ });
119
+ });
@@ -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
+ });