@servicetitan/dte-pdf-editor 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/README.md +35 -7
  2. package/dist/components/field-config-panel/advanced-settings.d.ts +9 -0
  3. package/dist/components/field-config-panel/advanced-settings.d.ts.map +1 -0
  4. package/dist/components/field-config-panel/advanced-settings.js +17 -0
  5. package/dist/components/field-config-panel/advanced-settings.js.map +1 -0
  6. package/dist/components/field-config-panel/field-config-panel-overlay.d.ts +4 -1
  7. package/dist/components/field-config-panel/field-config-panel-overlay.d.ts.map +1 -1
  8. package/dist/components/field-config-panel/field-config-panel-overlay.js +2 -2
  9. package/dist/components/field-config-panel/field-config-panel-overlay.js.map +1 -1
  10. package/dist/components/field-config-panel/field-config-panel.d.ts +4 -1
  11. package/dist/components/field-config-panel/field-config-panel.d.ts.map +1 -1
  12. package/dist/components/field-config-panel/field-config-panel.js +11 -5
  13. package/dist/components/field-config-panel/field-config-panel.js.map +1 -1
  14. package/dist/components/field-config-panel/field-sidebar.d.ts +13 -0
  15. package/dist/components/field-config-panel/field-sidebar.d.ts.map +1 -0
  16. package/dist/components/field-config-panel/field-sidebar.js +20 -0
  17. package/dist/components/field-config-panel/field-sidebar.js.map +1 -0
  18. package/dist/components/field-config-panel/formula-generator.d.ts +11 -0
  19. package/dist/components/field-config-panel/formula-generator.d.ts.map +1 -0
  20. package/dist/components/field-config-panel/formula-generator.js +51 -0
  21. package/dist/components/field-config-panel/formula-generator.js.map +1 -0
  22. package/dist/components/field-config-panel/formula-modal.d.ts +12 -0
  23. package/dist/components/field-config-panel/formula-modal.d.ts.map +1 -0
  24. package/dist/components/field-config-panel/formula-modal.js +99 -0
  25. package/dist/components/field-config-panel/formula-modal.js.map +1 -0
  26. package/dist/components/field-config-panel/formula-workspace.d.ts +23 -0
  27. package/dist/components/field-config-panel/formula-workspace.d.ts.map +1 -0
  28. package/dist/components/field-config-panel/formula-workspace.js +28 -0
  29. package/dist/components/field-config-panel/formula-workspace.js.map +1 -0
  30. package/dist/components/field-config-panel/result-type-selector.d.ts +9 -0
  31. package/dist/components/field-config-panel/result-type-selector.d.ts.map +1 -0
  32. package/dist/components/field-config-panel/result-type-selector.js +10 -0
  33. package/dist/components/field-config-panel/result-type-selector.js.map +1 -0
  34. package/dist/components/field-sidebar/calculated-field-type-list.d.ts +9 -0
  35. package/dist/components/field-sidebar/calculated-field-type-list.d.ts.map +1 -0
  36. package/dist/components/field-sidebar/calculated-field-type-list.js +12 -0
  37. package/dist/components/field-sidebar/calculated-field-type-list.js.map +1 -0
  38. package/dist/components/field-sidebar/data-model-field-type-list.d.ts +0 -1
  39. package/dist/components/field-sidebar/data-model-field-type-list.d.ts.map +1 -1
  40. package/dist/components/field-sidebar/data-model-field-type-list.js +8 -7
  41. package/dist/components/field-sidebar/data-model-field-type-list.js.map +1 -1
  42. package/dist/components/field-sidebar/field-menu-group.d.ts +11 -0
  43. package/dist/components/field-sidebar/field-menu-group.d.ts.map +1 -0
  44. package/dist/components/field-sidebar/field-menu-group.js +6 -0
  45. package/dist/components/field-sidebar/field-menu-group.js.map +1 -0
  46. package/dist/components/field-sidebar/field-sidebar.d.ts.map +1 -1
  47. package/dist/components/field-sidebar/field-sidebar.js +6 -15
  48. package/dist/components/field-sidebar/field-sidebar.js.map +1 -1
  49. package/dist/components/field-sidebar/fillable-field-type-list.d.ts +0 -1
  50. package/dist/components/field-sidebar/fillable-field-type-list.d.ts.map +1 -1
  51. package/dist/components/field-sidebar/fillable-field-type-list.js +8 -9
  52. package/dist/components/field-sidebar/fillable-field-type-list.js.map +1 -1
  53. package/dist/components/pdf-editor/pdf-editor.d.ts.map +1 -1
  54. package/dist/components/pdf-editor/pdf-editor.js +1 -1
  55. package/dist/components/pdf-editor/pdf-editor.js.map +1 -1
  56. package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.d.ts +8 -0
  57. package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.d.ts.map +1 -0
  58. package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.js +5 -0
  59. package/dist/components/pdf-fields-overlay/pdf-overlay-field-calculated.js.map +1 -0
  60. package/dist/components/pdf-fields-overlay/pdf-overlay-field.d.ts.map +1 -1
  61. package/dist/components/pdf-fields-overlay/pdf-overlay-field.js +11 -6
  62. package/dist/components/pdf-fields-overlay/pdf-overlay-field.js.map +1 -1
  63. package/dist/components/pdf-view/pdf-view-calculated.d.ts +9 -0
  64. package/dist/components/pdf-view/pdf-view-calculated.d.ts.map +1 -0
  65. package/dist/components/pdf-view/pdf-view-calculated.js +18 -0
  66. package/dist/components/pdf-view/pdf-view-calculated.js.map +1 -0
  67. package/dist/components/pdf-view/pdf-view.d.ts.map +1 -1
  68. package/dist/components/pdf-view/pdf-view.js +2 -1
  69. package/dist/components/pdf-view/pdf-view.js.map +1 -1
  70. package/dist/constants/field.constants.d.ts +3 -2
  71. package/dist/constants/field.constants.d.ts.map +1 -1
  72. package/dist/constants/field.constants.js +6 -0
  73. package/dist/constants/field.constants.js.map +1 -1
  74. package/dist/constants/menu-group.d.ts +8 -0
  75. package/dist/constants/menu-group.d.ts.map +1 -0
  76. package/dist/constants/menu-group.js +20 -0
  77. package/dist/constants/menu-group.js.map +1 -0
  78. package/dist/hooks/index.d.ts +1 -0
  79. package/dist/hooks/index.d.ts.map +1 -1
  80. package/dist/hooks/index.js +1 -0
  81. package/dist/hooks/index.js.map +1 -1
  82. package/dist/hooks/useFieldDrag.d.ts +9 -2
  83. package/dist/hooks/useFieldDrag.d.ts.map +1 -1
  84. package/dist/hooks/useFieldDrag.js +60 -8
  85. package/dist/hooks/useFieldDrag.js.map +1 -1
  86. package/dist/hooks/useFormulaEditor.d.ts +22 -0
  87. package/dist/hooks/useFormulaEditor.d.ts.map +1 -0
  88. package/dist/hooks/useFormulaEditor.js +290 -0
  89. package/dist/hooks/useFormulaEditor.js.map +1 -0
  90. package/dist/hooks/usePdfFieldDnD.d.ts.map +1 -1
  91. package/dist/hooks/usePdfFieldDnD.js +19 -3
  92. package/dist/hooks/usePdfFieldDnD.js.map +1 -1
  93. package/dist/interface/types.d.ts +45 -3
  94. package/dist/interface/types.d.ts.map +1 -1
  95. package/dist/interface/types.js +3 -0
  96. package/dist/interface/types.js.map +1 -1
  97. package/dist/utils/data-model/extract-fields.utils.d.ts +5 -5
  98. package/dist/utils/data-model/extract-fields.utils.d.ts.map +1 -1
  99. package/dist/utils/data-model/extract-fields.utils.js +42 -8
  100. package/dist/utils/data-model/extract-fields.utils.js.map +1 -1
  101. package/dist/utils/formula/caret.utils.d.ts +3 -0
  102. package/dist/utils/formula/caret.utils.d.ts.map +1 -0
  103. package/dist/utils/formula/caret.utils.js +123 -0
  104. package/dist/utils/formula/caret.utils.js.map +1 -0
  105. package/dist/utils/formula/dom.utils.d.ts +4 -0
  106. package/dist/utils/formula/dom.utils.d.ts.map +1 -0
  107. package/dist/utils/formula/dom.utils.js +34 -0
  108. package/dist/utils/formula/dom.utils.js.map +1 -0
  109. package/dist/utils/formula/evaluate-formula.utils.d.ts +13 -0
  110. package/dist/utils/formula/evaluate-formula.utils.d.ts.map +1 -0
  111. package/dist/utils/formula/evaluate-formula.utils.js +134 -0
  112. package/dist/utils/formula/evaluate-formula.utils.js.map +1 -0
  113. package/dist/utils/formula/expression.utils.d.ts +18 -0
  114. package/dist/utils/formula/expression.utils.d.ts.map +1 -0
  115. package/dist/utils/formula/expression.utils.js +84 -0
  116. package/dist/utils/formula/expression.utils.js.map +1 -0
  117. package/dist/utils/formula/format-calculated-result.utils.d.ts +7 -0
  118. package/dist/utils/formula/format-calculated-result.utils.d.ts.map +1 -0
  119. package/dist/utils/formula/format-calculated-result.utils.js +50 -0
  120. package/dist/utils/formula/format-calculated-result.utils.js.map +1 -0
  121. package/dist/utils/formula/formula-types.d.ts +3 -0
  122. package/dist/utils/formula/formula-types.d.ts.map +1 -0
  123. package/dist/utils/formula/formula-types.js +2 -0
  124. package/dist/utils/formula/formula-types.js.map +1 -0
  125. package/dist/utils/formula/index.d.ts +11 -0
  126. package/dist/utils/formula/index.d.ts.map +1 -0
  127. package/dist/utils/formula/index.js +11 -0
  128. package/dist/utils/formula/index.js.map +1 -0
  129. package/dist/utils/formula/referenced-paths.utils.d.ts +7 -0
  130. package/dist/utils/formula/referenced-paths.utils.d.ts.map +1 -0
  131. package/dist/utils/formula/referenced-paths.utils.js +18 -0
  132. package/dist/utils/formula/referenced-paths.utils.js.map +1 -0
  133. package/dist/utils/formula/render-formula.utils.d.ts +8 -0
  134. package/dist/utils/formula/render-formula.utils.d.ts.map +1 -0
  135. package/dist/utils/formula/render-formula.utils.js +39 -0
  136. package/dist/utils/formula/render-formula.utils.js.map +1 -0
  137. package/dist/utils/formula/serialize-formula.utils.d.ts +14 -0
  138. package/dist/utils/formula/serialize-formula.utils.d.ts.map +1 -0
  139. package/dist/utils/formula/serialize-formula.utils.js +33 -0
  140. package/dist/utils/formula/serialize-formula.utils.js.map +1 -0
  141. package/dist/utils/formula/validate-formula.utils.d.ts +11 -0
  142. package/dist/utils/formula/validate-formula.utils.d.ts.map +1 -0
  143. package/dist/utils/formula/validate-formula.utils.js +79 -0
  144. package/dist/utils/formula/validate-formula.utils.js.map +1 -0
  145. package/dist/utils/index.d.ts +1 -0
  146. package/dist/utils/index.d.ts.map +1 -1
  147. package/dist/utils/index.js +1 -0
  148. package/dist/utils/index.js.map +1 -1
  149. package/package.json +2 -2
  150. package/src/components/field-config-panel/advanced-settings.tsx +113 -0
  151. package/src/components/field-config-panel/field-config-panel-overlay.tsx +8 -1
  152. package/src/components/field-config-panel/field-config-panel.tsx +43 -15
  153. package/src/components/field-config-panel/field-sidebar.tsx +91 -0
  154. package/src/components/field-config-panel/formula-generator.tsx +122 -0
  155. package/src/components/field-config-panel/formula-modal.tsx +229 -0
  156. package/src/components/field-config-panel/formula-workspace.tsx +116 -0
  157. package/src/components/field-config-panel/result-type-selector.tsx +34 -0
  158. package/src/components/field-sidebar/calculated-field-type-list.tsx +29 -0
  159. package/src/components/field-sidebar/data-model-field-type-list.tsx +10 -4
  160. package/src/components/field-sidebar/field-menu-group.tsx +36 -0
  161. package/src/components/field-sidebar/field-sidebar.tsx +14 -55
  162. package/src/components/field-sidebar/fillable-field-type-list.tsx +11 -9
  163. package/src/components/pdf-editor/pdf-editor.tsx +2 -0
  164. package/src/components/pdf-fields-overlay/pdf-overlay-field-calculated.tsx +15 -0
  165. package/src/components/pdf-fields-overlay/pdf-overlay-field.tsx +10 -5
  166. package/src/components/pdf-view/pdf-view-calculated.tsx +23 -0
  167. package/src/components/pdf-view/pdf-view.tsx +4 -0
  168. package/src/constants/field.constants.ts +9 -2
  169. package/src/constants/menu-group.ts +26 -0
  170. package/src/hooks/index.ts +1 -0
  171. package/src/hooks/useFieldDrag.ts +84 -8
  172. package/src/hooks/useFormulaEditor.ts +336 -0
  173. package/src/hooks/usePdfFieldDnD.ts +36 -14
  174. package/src/interface/types.ts +38 -2
  175. package/src/styles/formula-modal.css +307 -0
  176. package/src/styles/index.css +1 -0
  177. package/src/styles/pdf-field-overlay.css +1 -0
  178. package/src/utils/data-model/extract-fields.utils.ts +65 -7
  179. package/src/utils/formula/caret.utils.ts +125 -0
  180. package/src/utils/formula/dom.utils.ts +35 -0
  181. package/src/utils/formula/evaluate-formula.utils.ts +159 -0
  182. package/src/utils/formula/expression.utils.ts +99 -0
  183. package/src/utils/formula/format-calculated-result.utils.ts +79 -0
  184. package/src/utils/formula/formula-types.ts +2 -0
  185. package/src/utils/formula/index.ts +10 -0
  186. package/src/utils/formula/referenced-paths.utils.ts +18 -0
  187. package/src/utils/formula/render-formula.utils.ts +40 -0
  188. package/src/utils/formula/serialize-formula.utils.ts +40 -0
  189. package/src/utils/formula/validate-formula.utils.ts +94 -0
  190. package/src/utils/index.ts +1 -0
@@ -1,10 +1,13 @@
1
1
  import { Button, Flex, Text } from '@servicetitan/anvil2';
2
2
  import IconClose from '@servicetitan/anvil2/assets/icons/material/round/close.svg';
3
3
  import { FC } from 'react';
4
- import { PdfField, RecipientInfo } from '../../interface/types';
4
+ import { PdfField, RecipientInfo, SchemaObject } from '../../interface/types';
5
5
  import { FieldConfigPanel } from './field-config-panel';
6
6
 
7
7
  interface FieldConfigPanelOverlayProps {
8
+ dataModel?: SchemaObject;
9
+ /** All fields on the document (e.g. for formula builder fillable section). */
10
+ documentFields?: PdfField[];
8
11
  selectedField: PdfField;
9
12
  recipients?: RecipientInfo[];
10
13
  onFieldConfigChange(updates: Partial<PdfField>): void;
@@ -13,6 +16,8 @@ interface FieldConfigPanelOverlayProps {
13
16
  }
14
17
 
15
18
  export const FieldConfigPanelOverlay: FC<FieldConfigPanelOverlayProps> = ({
19
+ dataModel,
20
+ documentFields = [],
16
21
  onDeleteField,
17
22
  onDeselectField,
18
23
  onFieldConfigChange,
@@ -39,6 +44,8 @@ export const FieldConfigPanelOverlay: FC<FieldConfigPanelOverlayProps> = ({
39
44
  </Flex>
40
45
  <div className="dte-field-config-panel-content">
41
46
  <FieldConfigPanel
47
+ dataModel={dataModel}
48
+ documentFields={documentFields}
42
49
  field={selectedField}
43
50
  recipients={recipients}
44
51
  onFieldConfigChange={onFieldConfigChange}
@@ -1,17 +1,29 @@
1
- import { Button, Combobox, Flex, Textarea, TextField } from '@servicetitan/anvil2';
1
+ import { Button, Checkbox, Combobox, Flex, Textarea, TextField } from '@servicetitan/anvil2';
2
2
  import { FC, useMemo } from 'react';
3
3
  import { E_SIGN_FIELD_TYPE_OPTIONS } from '../../constants';
4
- import { ESignFieldType, FieldTypeEnum, PdfField, RecipientInfo } from '../../interface/types';
4
+ import {
5
+ ESignFieldType,
6
+ FieldTypeEnum,
7
+ PdfField,
8
+ RecipientInfo,
9
+ SchemaObject,
10
+ } from '../../interface/types';
5
11
  import { generateESignPath, generateFillablePath } from '../../utils';
12
+ import { FormulaGenerator } from './formula-generator';
6
13
 
7
14
  interface FieldConfigPanelProps {
8
15
  field: PdfField;
16
+ dataModel?: SchemaObject;
17
+ /** All fields on the document (e.g. for formula builder fillable section). */
18
+ documentFields?: PdfField[];
9
19
  recipients?: RecipientInfo[];
10
20
  onDeleteField(): void;
11
21
  onFieldConfigChange(updates: Partial<PdfField>): void;
12
22
  }
13
23
 
14
24
  export const FieldConfigPanel: FC<FieldConfigPanelProps> = ({
25
+ dataModel,
26
+ documentFields = [],
15
27
  field,
16
28
  onDeleteField,
17
29
  onFieldConfigChange,
@@ -63,8 +75,11 @@ export const FieldConfigPanel: FC<FieldConfigPanelProps> = ({
63
75
 
64
76
  return (
65
77
  <Flex direction="column" gap="4">
66
- {(field.type === FieldTypeEnum.fillable || field.type === FieldTypeEnum.eSign) && (
78
+ {[FieldTypeEnum.fillable, FieldTypeEnum.eSign, FieldTypeEnum.calculated].includes(
79
+ field.type,
80
+ ) && (
67
81
  <TextField
82
+ required
68
83
  label="Label"
69
84
  value={field.label}
70
85
  onChange={e =>
@@ -118,18 +133,31 @@ export const FieldConfigPanel: FC<FieldConfigPanelProps> = ({
118
133
  </Combobox>
119
134
  )}
120
135
  <TextField label="Data Path" value={field.path} disabled />
121
- {/* Todo need to uncomment when in MFE starts handle validations */}
122
- {/* {field.type === FieldTypeEnum.fillable && (*/}
123
- {/* <Checkbox*/}
124
- {/* label="Required"*/}
125
- {/* checked={field.required}*/}
126
- {/* onChange={() =>*/}
127
- {/* onFieldConfigChange({*/}
128
- {/* required: !field.required,*/}
129
- {/* })*/}
130
- {/* }*/}
131
- {/* />*/}
132
- {/* )}*/}
136
+ {field.type === FieldTypeEnum.fillable && field.subType !== 'checkbox' && (
137
+ <Checkbox
138
+ label="Required"
139
+ checked={field.required}
140
+ onChange={() =>
141
+ onFieldConfigChange({
142
+ required: !field.required,
143
+ })
144
+ }
145
+ />
146
+ )}
147
+ {field.type === FieldTypeEnum.calculated && (
148
+ <FormulaGenerator
149
+ dataModel={dataModel}
150
+ documentFields={documentFields}
151
+ formula={field.formula}
152
+ formulaFormat={field.formulaFormat}
153
+ onFormulaChange={(formula, formulaFormat) =>
154
+ onFieldConfigChange({
155
+ formula,
156
+ formulaFormat,
157
+ })
158
+ }
159
+ />
160
+ )}
133
161
  <Textarea
134
162
  label="Description"
135
163
  value={field.description}
@@ -0,0 +1,91 @@
1
+ import { Text } from '@servicetitan/anvil2';
2
+ import { FC } from 'react';
3
+ import { FieldTypeOption } from '../../interface/types';
4
+
5
+ interface FieldSidebarProps {
6
+ fillableOptions: FieldTypeOption[];
7
+ mergeTagOptions: FieldTypeOption[];
8
+ highlightElementPath: string;
9
+ onHover: (path: string) => void;
10
+ onSelect: (path: string) => void;
11
+ selectedPaths: Set<string>;
12
+ }
13
+
14
+ export const FieldSidebar: FC<FieldSidebarProps> = ({
15
+ fillableOptions,
16
+ highlightElementPath,
17
+ mergeTagOptions,
18
+ onHover,
19
+ onSelect,
20
+ selectedPaths,
21
+ }) => {
22
+ return (
23
+ <div className="dte-formula-sidebar">
24
+ <FieldOptionList
25
+ title="Fillable fields"
26
+ options={fillableOptions}
27
+ highlightElementPath={highlightElementPath}
28
+ selectedPaths={selectedPaths}
29
+ onHover={onHover}
30
+ onSelect={onSelect}
31
+ />
32
+ <FieldOptionList
33
+ title="Merge tags"
34
+ options={mergeTagOptions}
35
+ highlightElementPath={highlightElementPath}
36
+ selectedPaths={selectedPaths}
37
+ onHover={onHover}
38
+ onSelect={onSelect}
39
+ />
40
+ </div>
41
+ );
42
+ };
43
+
44
+ interface FieldOptionListProps {
45
+ highlightElementPath: string;
46
+ onHover: (path: string) => void;
47
+ onSelect: (path: string) => void;
48
+ options: FieldTypeOption[];
49
+ selectedPaths: Set<string>;
50
+ title: string;
51
+ }
52
+
53
+ const FieldOptionList: FC<FieldOptionListProps> = ({
54
+ highlightElementPath,
55
+ onHover,
56
+ onSelect,
57
+ options,
58
+ selectedPaths,
59
+ title,
60
+ }) => {
61
+ if (options.length === 0) {
62
+ return null;
63
+ }
64
+
65
+ return (
66
+ <div>
67
+ <Text variant="headline" el="h6" size="small">
68
+ {title}
69
+ </Text>
70
+ <ul className="dte-formula-field-list" role="listbox" aria-label={title}>
71
+ {options.map(opt => (
72
+ <li
73
+ key={`${opt.path}-${opt.label}`}
74
+ role="option"
75
+ aria-selected={opt.path === highlightElementPath}
76
+ className={`dte-formula-field-list-item ${opt.path === highlightElementPath ? '--highlight' : ''} ${selectedPaths.has(opt.path ?? '') ? '--selected' : ''}`}
77
+ onMouseEnter={() => opt.path && onHover(opt.path)}
78
+ onMouseDown={e => {
79
+ e.preventDefault();
80
+ if (opt.path) {
81
+ onSelect(opt.path);
82
+ }
83
+ }}
84
+ >
85
+ {opt.label ?? opt.path}
86
+ </li>
87
+ ))}
88
+ </ul>
89
+ </div>
90
+ );
91
+ };
@@ -0,0 +1,122 @@
1
+ import { Text } from '@servicetitan/anvil2';
2
+ import { FC, Fragment, useMemo, useState } from 'react';
3
+ import {
4
+ CalculatedFieldFormat,
5
+ FieldTypeEnum,
6
+ FieldTypeOption,
7
+ FormulaToken,
8
+ PdfField,
9
+ PdfFieldSubType,
10
+ SchemaObject,
11
+ StructuredFormula,
12
+ } from '../../interface/types';
13
+ import { extractGroupedFieldsFromDataModel } from '../../utils';
14
+ import { FormulaModal } from './formula-modal';
15
+
16
+ export interface FormulaGeneratorProps {
17
+ dataModel?: SchemaObject;
18
+ documentFields?: PdfField[];
19
+ formula?: StructuredFormula;
20
+ formulaFormat?: CalculatedFieldFormat;
21
+ onFormulaChange?(formula: StructuredFormula, format: CalculatedFieldFormat): void;
22
+ }
23
+
24
+ function getFillableFilteredBuySubTypeFields(
25
+ fields: PdfField[],
26
+ subTypes: PdfFieldSubType[],
27
+ ): FieldTypeOption[] {
28
+ return fields
29
+ .filter(f => f.type === FieldTypeEnum.fillable && subTypes.includes(f.subType!))
30
+ .map(f => ({
31
+ label: (f.label || f.path) ?? '',
32
+ type: FieldTypeEnum.fillable,
33
+ subType: f.subType,
34
+ path: f.path,
35
+ }));
36
+ }
37
+
38
+ export const FormulaGenerator: FC<FormulaGeneratorProps> = ({
39
+ dataModel,
40
+ documentFields = [],
41
+ formula,
42
+ formulaFormat,
43
+ onFormulaChange,
44
+ }) => {
45
+ const [modalOpen, setModalOpen] = useState(false);
46
+ const dataModelGroups = useMemo(
47
+ () =>
48
+ dataModel
49
+ ? extractGroupedFieldsFromDataModel(dataModel, {
50
+ onlyUseInCalculatedFields: true,
51
+ })
52
+ : [],
53
+ [dataModel],
54
+ );
55
+ const fillableFieldsFromDocument = useMemo(
56
+ () => getFillableFilteredBuySubTypeFields(documentFields, ['number']),
57
+ [documentFields],
58
+ );
59
+
60
+ const hasFormula = formula?.tokens && formula.tokens.length > 0;
61
+
62
+ const handleSave = (newFormula: StructuredFormula, format: CalculatedFieldFormat) => {
63
+ onFormulaChange?.(newFormula, format);
64
+ };
65
+
66
+ return (
67
+ <Fragment>
68
+ <Text variant="body" size="small">
69
+ Formula
70
+ </Text>
71
+ <div
72
+ className="dte-formula-box"
73
+ role="button"
74
+ tabIndex={0}
75
+ onClick={() => setModalOpen(true)}
76
+ onKeyDown={e => {
77
+ if (e.key === 'Enter' || e.key === ' ') {
78
+ e.preventDefault();
79
+ setModalOpen(true);
80
+ }
81
+ }}
82
+ aria-label="Configure formula"
83
+ >
84
+ {hasFormula ? (
85
+ (() => {
86
+ let offset = 0;
87
+ return formula!.tokens.map((token: FormulaToken) => {
88
+ const value =
89
+ token.type === 'field'
90
+ ? token.path
91
+ : (token as { value: string }).value;
92
+ const key = `formula-${offset}-${token.type}-${value}`;
93
+ offset += value.length + 1;
94
+ return (
95
+ <span
96
+ key={key}
97
+ className={`dte-formula-token dte-formula-token-${token.type}`}
98
+ >
99
+ {token.type === 'field'
100
+ ? token.label
101
+ : (token as { value: string }).value}
102
+ </span>
103
+ );
104
+ });
105
+ })()
106
+ ) : (
107
+ <span>Configure formula</span>
108
+ )}
109
+ </div>
110
+ {modalOpen && (
111
+ <FormulaModal
112
+ initialFormula={formula}
113
+ initialFormat={formulaFormat}
114
+ dataModelGroups={dataModelGroups}
115
+ fillableFieldsFromDocument={fillableFieldsFromDocument}
116
+ onClose={() => setModalOpen(false)}
117
+ onSave={handleSave}
118
+ />
119
+ )}
120
+ </Fragment>
121
+ );
122
+ };
@@ -0,0 +1,229 @@
1
+ import { Button, Flex, Text } from '@servicetitan/anvil2';
2
+ import IconClose from '@servicetitan/anvil2/assets/icons/material/round/close.svg';
3
+ import IconRedo from '@servicetitan/anvil2/assets/icons/material/round/redo.svg';
4
+ import IconUndo from '@servicetitan/anvil2/assets/icons/material/round/undo.svg';
5
+ import { FC, useCallback, useMemo, useState } from 'react';
6
+ import { useFormulaEditor } from '../../hooks';
7
+ import {
8
+ CalculatedFieldFormat,
9
+ DataModelFieldGroup,
10
+ FieldTypeOption,
11
+ StructuredFormula,
12
+ } from '../../interface/types';
13
+ import {
14
+ parseExpression,
15
+ tokenizeExpression,
16
+ tokensToExpression,
17
+ validateFormula,
18
+ } from '../../utils';
19
+ import { FieldSidebar } from './field-sidebar';
20
+ import { FormulaWorkspace } from './formula-workspace';
21
+
22
+ const DEFAULT_CALCULATED_FIELD_FORMAT: CalculatedFieldFormat = {
23
+ resultType: 'number',
24
+ thousandsSeparator: false,
25
+ decimals: 2,
26
+ roundingMode: 'round',
27
+ decimalSeparatorEnabled: false,
28
+ decimalSeparator: '.',
29
+ prefixText: '',
30
+ postfixText: '',
31
+ };
32
+
33
+ function getAllFields(groups: DataModelFieldGroup[]): FieldTypeOption[] {
34
+ const list: FieldTypeOption[] = [];
35
+ for (const g of groups) {
36
+ for (const f of g.fields) {
37
+ list.push(f);
38
+ }
39
+ }
40
+ return list;
41
+ }
42
+
43
+ function getValidPathsSet(groups: DataModelFieldGroup[]): Set<string> {
44
+ const set = new Set<string>();
45
+ for (const f of getAllFields(groups)) {
46
+ if (f.path) {
47
+ set.add(f.path);
48
+ }
49
+ }
50
+ return set;
51
+ }
52
+
53
+ export interface FormulaModalProps {
54
+ initialFormula: StructuredFormula | undefined;
55
+ initialFormat?: CalculatedFieldFormat;
56
+ dataModelGroups: DataModelFieldGroup[];
57
+ fillableFieldsFromDocument?: FieldTypeOption[];
58
+ onClose(): void;
59
+ onSave(formula: StructuredFormula, format: CalculatedFieldFormat): void;
60
+ }
61
+
62
+ export const FormulaModal: FC<FormulaModalProps> = ({
63
+ dataModelGroups,
64
+ fillableFieldsFromDocument = [],
65
+ initialFormat,
66
+ initialFormula,
67
+ onClose,
68
+ onSave,
69
+ }) => {
70
+ const validPaths = useMemo(() => {
71
+ const set = getValidPathsSet(dataModelGroups);
72
+ for (const f of fillableFieldsFromDocument) {
73
+ if (f.path) {
74
+ set.add(f.path);
75
+ }
76
+ }
77
+ return set;
78
+ }, [dataModelGroups, fillableFieldsFromDocument]);
79
+ const allFields = useMemo(() => getAllFields(dataModelGroups), [dataModelGroups]);
80
+ const pathToLabel = useMemo(() => {
81
+ const m = new Map<string, string>();
82
+ for (const f of fillableFieldsFromDocument) {
83
+ if (f.path) {
84
+ m.set(f.path, f.label ?? f.path);
85
+ }
86
+ }
87
+ for (const f of allFields) {
88
+ if (f.path) {
89
+ m.set(f.path, f.label ?? f.path);
90
+ }
91
+ }
92
+ return m;
93
+ }, [allFields, fillableFieldsFromDocument]);
94
+
95
+ const currentExpression = useMemo(() => tokensToExpression(initialFormula), [initialFormula]);
96
+
97
+ const [highlightElementPath, setHighlightElementPath] = useState('');
98
+ const [format, setFormat] = useState<CalculatedFieldFormat>(
99
+ () => initialFormat ?? DEFAULT_CALCULATED_FIELD_FORMAT,
100
+ );
101
+ const [advancedOpen, setAdvancedOpen] = useState(false);
102
+
103
+ const formulaEditor = useFormulaEditor({
104
+ currentExpression,
105
+ opened: true,
106
+ pathToLabel,
107
+ });
108
+
109
+ const parsedTokens = useMemo(
110
+ () => parseExpression(formulaEditor.draftExpression, validPaths, pathToLabel),
111
+ [formulaEditor.draftExpression, validPaths, pathToLabel],
112
+ );
113
+
114
+ const formulaValidation = useMemo(
115
+ () => validateFormula({ tokens: parsedTokens }, validPaths),
116
+ [parsedTokens, validPaths],
117
+ );
118
+
119
+ const unknownFieldErrors = useMemo(() => {
120
+ const parts = tokenizeExpression(formulaEditor.draftExpression);
121
+ const errors: string[] = [];
122
+ for (const p of parts) {
123
+ if (p.type === 'field' && !validPaths.has(p.value)) {
124
+ errors.push(`Unknown field: "${p.value}"`);
125
+ }
126
+ }
127
+ return errors;
128
+ }, [formulaEditor.draftExpression, validPaths]);
129
+
130
+ const validationError = unknownFieldErrors[0] ?? formulaValidation.errors[0] ?? '';
131
+ const isInvalid = !formulaValidation.valid || unknownFieldErrors.length > 0;
132
+ const canSave =
133
+ formulaValidation.valid && unknownFieldErrors.length === 0 && parsedTokens.length > 0;
134
+
135
+ const handleSave = useCallback(() => {
136
+ if (!canSave) {
137
+ return;
138
+ }
139
+ onSave({ tokens: parsedTokens }, format);
140
+ onClose();
141
+ }, [canSave, format, onClose, onSave, parsedTokens]);
142
+
143
+ return (
144
+ <Flex
145
+ alignItems="center"
146
+ justifyContent="center"
147
+ className="dte-formula-modal-overlay"
148
+ role="dialog"
149
+ aria-modal="true"
150
+ aria-label="Formula"
151
+ >
152
+ <Flex direction="column" className="dte-formula-modal ">
153
+ <Flex
154
+ className="dte-formula-modal-header"
155
+ alignItems="center"
156
+ justifyContent="space-between"
157
+ >
158
+ <Text variant="headline" el="h6" size="small">
159
+ Formula Builder
160
+ </Text>
161
+ <Button
162
+ appearance="ghost"
163
+ size="small"
164
+ onClick={onClose}
165
+ aria-label="Close"
166
+ icon={IconClose}
167
+ />
168
+ </Flex>
169
+ <div className="dte-formula-modal-body">
170
+ <Flex alignItems="stretch" gap={2} className="dte-formula-modal-columns">
171
+ <FieldSidebar
172
+ fillableOptions={fillableFieldsFromDocument}
173
+ mergeTagOptions={allFields}
174
+ highlightElementPath={highlightElementPath}
175
+ onHover={setHighlightElementPath}
176
+ onSelect={formulaEditor.insertField}
177
+ selectedPaths={formulaEditor.selectedFieldPaths}
178
+ />
179
+ <FormulaWorkspace
180
+ editorRef={formulaEditor.editorRef}
181
+ isInvalid={isInvalid && formulaEditor.isDirty}
182
+ validationError={validationError}
183
+ format={format}
184
+ onResultTypeChange={nextType =>
185
+ setFormat(prev => ({ ...prev, resultType: nextType }))
186
+ }
187
+ advancedOpen={advancedOpen}
188
+ onToggleAdvanced={() => setAdvancedOpen(prev => !prev)}
189
+ setFormat={setFormat}
190
+ onInput={formulaEditor.handleEditorInput}
191
+ onKeyDown={formulaEditor.handleKeyDown}
192
+ onClick={() => {}}
193
+ onMouseUp={formulaEditor.handleEditorInput}
194
+ onPaste={formulaEditor.handlePaste}
195
+ onOperatorSelect={formulaEditor.insertOperator}
196
+ onRemoveField={formulaEditor.removeFieldAtIndex}
197
+ actions={
198
+ <Flex gap="1" alignItems="center">
199
+ <Button
200
+ appearance="ghost"
201
+ size="small"
202
+ onClick={formulaEditor.undo}
203
+ disabled={!formulaEditor.canUndo}
204
+ aria-label="Undo"
205
+ icon={IconUndo}
206
+ />
207
+ <Button
208
+ appearance="ghost"
209
+ size="small"
210
+ onClick={formulaEditor.redo}
211
+ disabled={!formulaEditor.canRedo}
212
+ aria-label="Redo"
213
+ icon={IconRedo}
214
+ />
215
+ </Flex>
216
+ }
217
+ />
218
+ </Flex>
219
+ </div>
220
+ <Flex className="dte-formula-modal-footer" gap="2" justifyContent="flex-end">
221
+ <Button onClick={onClose}>Cancel</Button>
222
+ <Button onClick={handleSave} appearance="primary" disabled={!canSave}>
223
+ Save
224
+ </Button>
225
+ </Flex>
226
+ </Flex>
227
+ </Flex>
228
+ );
229
+ };
@@ -0,0 +1,116 @@
1
+ import { Alert, Button, Flex, Text } from '@servicetitan/anvil2';
2
+ import {
3
+ Dispatch,
4
+ FC,
5
+ KeyboardEvent,
6
+ MouseEvent,
7
+ ReactNode,
8
+ RefObject,
9
+ SetStateAction,
10
+ useCallback,
11
+ } from 'react';
12
+ import { CalculatedFieldFormat } from '../../interface/types';
13
+ import { AdvancedSettings } from './advanced-settings';
14
+ import { ResultTypeSelector } from './result-type-selector';
15
+
16
+ interface FormulaWorkspaceProps {
17
+ actions: ReactNode;
18
+ advancedOpen: boolean;
19
+ editorRef: RefObject<HTMLDivElement | null>;
20
+ format: CalculatedFieldFormat;
21
+ isInvalid: boolean;
22
+ onClick: (e: MouseEvent<HTMLDivElement>) => void;
23
+ onInput: () => void;
24
+ onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => void;
25
+ onMouseUp: () => void;
26
+ onOperatorSelect: (op: string) => void;
27
+ onPaste: (text: string) => void;
28
+ onRemoveField: (fieldIndex: number) => void;
29
+ onResultTypeChange: (nextType: CalculatedFieldFormat['resultType']) => void;
30
+ onToggleAdvanced: () => void;
31
+ setFormat: Dispatch<SetStateAction<CalculatedFieldFormat>>;
32
+ validationError: string;
33
+ }
34
+
35
+ export const FormulaWorkspace: FC<FormulaWorkspaceProps> = ({
36
+ actions,
37
+ advancedOpen,
38
+ editorRef,
39
+ format,
40
+ isInvalid,
41
+ onClick,
42
+ onInput,
43
+ onKeyDown,
44
+ onMouseUp,
45
+ onOperatorSelect,
46
+ onPaste,
47
+ onRemoveField,
48
+ onResultTypeChange,
49
+ onToggleAdvanced,
50
+ setFormat,
51
+ validationError,
52
+ }) => {
53
+ const handleClick = useCallback(
54
+ (e: MouseEvent<HTMLDivElement>) => {
55
+ const target = e.target as HTMLElement;
56
+ const removeBtn = target.closest('[data-field-remove="true"]');
57
+ if (removeBtn) {
58
+ e.preventDefault();
59
+ e.stopPropagation();
60
+ const chip = removeBtn.closest('[data-field-index]') as HTMLElement | null;
61
+ const idx = chip?.dataset?.fieldIndex;
62
+ if (idx !== undefined) {
63
+ onRemoveField(Number(idx));
64
+ }
65
+ return;
66
+ }
67
+ onClick(e);
68
+ },
69
+ [onClick, onRemoveField],
70
+ );
71
+
72
+ return (
73
+ <Flex direction="column" flex={1} gap={2}>
74
+ <Text variant="body" size="small">
75
+ Click a field on the left to add it. Use +, -, *, /, and parentheses to build
76
+ formulas.
77
+ </Text>
78
+ <Flex alignItems="center" justifyContent="space-between">
79
+ <Text variant="body" size="small">
80
+ Formula
81
+ </Text>
82
+ {actions}
83
+ </Flex>
84
+ <div
85
+ ref={editorRef as RefObject<HTMLDivElement>}
86
+ className={`dte-formula-editor ${isInvalid ? 'dte-formula-editor-invalid' : ''}`}
87
+ role="textbox"
88
+ contentEditable
89
+ suppressContentEditableWarning
90
+ data-placeholder="e.g. order.total + order.tax"
91
+ onInput={onInput}
92
+ onKeyDown={onKeyDown}
93
+ onClick={handleClick}
94
+ onKeyUp={onInput}
95
+ onMouseUp={onMouseUp}
96
+ onPaste={e => {
97
+ e.preventDefault();
98
+ onPaste(e.clipboardData.getData('text/plain'));
99
+ }}
100
+ />
101
+ {isInvalid && validationError && <Alert status="danger" title={validationError} />}
102
+ <Flex gap={1}>
103
+ {['+', '-', '*', '/', '(', ')'].map(op => (
104
+ <Button key={op} onClick={() => onOperatorSelect(op)} size="small">
105
+ {op}
106
+ </Button>
107
+ ))}
108
+ </Flex>
109
+ <ResultTypeSelector format={format} onChange={onResultTypeChange} />
110
+ <Button appearance="ghost" onClick={onToggleAdvanced} size="small">
111
+ {advancedOpen ? 'Hide advanced settings' : 'Show advanced settings'}
112
+ </Button>
113
+ {advancedOpen && <AdvancedSettings format={format} setFormat={setFormat} />}
114
+ </Flex>
115
+ );
116
+ };