@servicetitan/dte-pdf-editor 1.17.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 (184) 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 +2 -1
  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/useFormulaEditor.d.ts +22 -0
  83. package/dist/hooks/useFormulaEditor.d.ts.map +1 -0
  84. package/dist/hooks/useFormulaEditor.js +290 -0
  85. package/dist/hooks/useFormulaEditor.js.map +1 -0
  86. package/dist/hooks/usePdfFieldDnD.d.ts.map +1 -1
  87. package/dist/hooks/usePdfFieldDnD.js +3 -0
  88. package/dist/hooks/usePdfFieldDnD.js.map +1 -1
  89. package/dist/interface/types.d.ts +45 -3
  90. package/dist/interface/types.d.ts.map +1 -1
  91. package/dist/interface/types.js +3 -0
  92. package/dist/interface/types.js.map +1 -1
  93. package/dist/utils/data-model/extract-fields.utils.d.ts +5 -5
  94. package/dist/utils/data-model/extract-fields.utils.d.ts.map +1 -1
  95. package/dist/utils/data-model/extract-fields.utils.js +42 -8
  96. package/dist/utils/data-model/extract-fields.utils.js.map +1 -1
  97. package/dist/utils/formula/caret.utils.d.ts +3 -0
  98. package/dist/utils/formula/caret.utils.d.ts.map +1 -0
  99. package/dist/utils/formula/caret.utils.js +123 -0
  100. package/dist/utils/formula/caret.utils.js.map +1 -0
  101. package/dist/utils/formula/dom.utils.d.ts +4 -0
  102. package/dist/utils/formula/dom.utils.d.ts.map +1 -0
  103. package/dist/utils/formula/dom.utils.js +34 -0
  104. package/dist/utils/formula/dom.utils.js.map +1 -0
  105. package/dist/utils/formula/evaluate-formula.utils.d.ts +13 -0
  106. package/dist/utils/formula/evaluate-formula.utils.d.ts.map +1 -0
  107. package/dist/utils/formula/evaluate-formula.utils.js +134 -0
  108. package/dist/utils/formula/evaluate-formula.utils.js.map +1 -0
  109. package/dist/utils/formula/expression.utils.d.ts +18 -0
  110. package/dist/utils/formula/expression.utils.d.ts.map +1 -0
  111. package/dist/utils/formula/expression.utils.js +84 -0
  112. package/dist/utils/formula/expression.utils.js.map +1 -0
  113. package/dist/utils/formula/format-calculated-result.utils.d.ts +7 -0
  114. package/dist/utils/formula/format-calculated-result.utils.d.ts.map +1 -0
  115. package/dist/utils/formula/format-calculated-result.utils.js +50 -0
  116. package/dist/utils/formula/format-calculated-result.utils.js.map +1 -0
  117. package/dist/utils/formula/formula-types.d.ts +3 -0
  118. package/dist/utils/formula/formula-types.d.ts.map +1 -0
  119. package/dist/utils/formula/formula-types.js +2 -0
  120. package/dist/utils/formula/formula-types.js.map +1 -0
  121. package/dist/utils/formula/index.d.ts +11 -0
  122. package/dist/utils/formula/index.d.ts.map +1 -0
  123. package/dist/utils/formula/index.js +11 -0
  124. package/dist/utils/formula/index.js.map +1 -0
  125. package/dist/utils/formula/referenced-paths.utils.d.ts +7 -0
  126. package/dist/utils/formula/referenced-paths.utils.d.ts.map +1 -0
  127. package/dist/utils/formula/referenced-paths.utils.js +18 -0
  128. package/dist/utils/formula/referenced-paths.utils.js.map +1 -0
  129. package/dist/utils/formula/render-formula.utils.d.ts +8 -0
  130. package/dist/utils/formula/render-formula.utils.d.ts.map +1 -0
  131. package/dist/utils/formula/render-formula.utils.js +39 -0
  132. package/dist/utils/formula/render-formula.utils.js.map +1 -0
  133. package/dist/utils/formula/serialize-formula.utils.d.ts +14 -0
  134. package/dist/utils/formula/serialize-formula.utils.d.ts.map +1 -0
  135. package/dist/utils/formula/serialize-formula.utils.js +33 -0
  136. package/dist/utils/formula/serialize-formula.utils.js.map +1 -0
  137. package/dist/utils/formula/validate-formula.utils.d.ts +11 -0
  138. package/dist/utils/formula/validate-formula.utils.d.ts.map +1 -0
  139. package/dist/utils/formula/validate-formula.utils.js +79 -0
  140. package/dist/utils/formula/validate-formula.utils.js.map +1 -0
  141. package/dist/utils/index.d.ts +1 -0
  142. package/dist/utils/index.d.ts.map +1 -1
  143. package/dist/utils/index.js +1 -0
  144. package/dist/utils/index.js.map +1 -1
  145. package/package.json +2 -2
  146. package/src/components/field-config-panel/advanced-settings.tsx +113 -0
  147. package/src/components/field-config-panel/field-config-panel-overlay.tsx +8 -1
  148. package/src/components/field-config-panel/field-config-panel.tsx +43 -15
  149. package/src/components/field-config-panel/field-sidebar.tsx +91 -0
  150. package/src/components/field-config-panel/formula-generator.tsx +122 -0
  151. package/src/components/field-config-panel/formula-modal.tsx +229 -0
  152. package/src/components/field-config-panel/formula-workspace.tsx +116 -0
  153. package/src/components/field-config-panel/result-type-selector.tsx +34 -0
  154. package/src/components/field-sidebar/calculated-field-type-list.tsx +29 -0
  155. package/src/components/field-sidebar/data-model-field-type-list.tsx +10 -4
  156. package/src/components/field-sidebar/field-menu-group.tsx +36 -0
  157. package/src/components/field-sidebar/field-sidebar.tsx +14 -55
  158. package/src/components/field-sidebar/fillable-field-type-list.tsx +11 -9
  159. package/src/components/pdf-editor/pdf-editor.tsx +2 -0
  160. package/src/components/pdf-fields-overlay/pdf-overlay-field-calculated.tsx +15 -0
  161. package/src/components/pdf-fields-overlay/pdf-overlay-field.tsx +2 -0
  162. package/src/components/pdf-view/pdf-view-calculated.tsx +23 -0
  163. package/src/components/pdf-view/pdf-view.tsx +4 -0
  164. package/src/constants/field.constants.ts +9 -2
  165. package/src/constants/menu-group.ts +26 -0
  166. package/src/hooks/index.ts +1 -0
  167. package/src/hooks/useFormulaEditor.ts +336 -0
  168. package/src/hooks/usePdfFieldDnD.ts +4 -0
  169. package/src/interface/types.ts +38 -2
  170. package/src/styles/formula-modal.css +307 -0
  171. package/src/styles/index.css +1 -0
  172. package/src/utils/data-model/extract-fields.utils.ts +65 -7
  173. package/src/utils/formula/caret.utils.ts +125 -0
  174. package/src/utils/formula/dom.utils.ts +35 -0
  175. package/src/utils/formula/evaluate-formula.utils.ts +159 -0
  176. package/src/utils/formula/expression.utils.ts +99 -0
  177. package/src/utils/formula/format-calculated-result.utils.ts +79 -0
  178. package/src/utils/formula/formula-types.ts +2 -0
  179. package/src/utils/formula/index.ts +10 -0
  180. package/src/utils/formula/referenced-paths.utils.ts +18 -0
  181. package/src/utils/formula/render-formula.utils.ts +40 -0
  182. package/src/utils/formula/serialize-formula.utils.ts +40 -0
  183. package/src/utils/formula/validate-formula.utils.ts +94 -0
  184. package/src/utils/index.ts +1 -0
@@ -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
+ };
@@ -0,0 +1,34 @@
1
+ import { Button, Flex, Text } from '@servicetitan/anvil2';
2
+ import { FC, Fragment } from 'react';
3
+ import { CalculatedFieldFormat } from '../../interface/types';
4
+
5
+ interface ResultTypeSelectorProps {
6
+ format: CalculatedFieldFormat;
7
+ onChange: (nextType: CalculatedFieldFormat['resultType']) => void;
8
+ }
9
+
10
+ const RESULT_TYPE_OPTIONS: { label: string; value: CalculatedFieldFormat['resultType'] }[] = [
11
+ { label: 'Number', value: 'number' },
12
+ { label: 'Currency', value: 'currency' },
13
+ { label: 'Percent', value: 'percent' },
14
+ ];
15
+
16
+ export const ResultTypeSelector: FC<ResultTypeSelectorProps> = ({ format, onChange }) => (
17
+ <Fragment>
18
+ <Text variant="body" size="small">
19
+ Result type
20
+ </Text>
21
+ <Flex gap={2}>
22
+ {RESULT_TYPE_OPTIONS.map(({ label, value }) => (
23
+ <Button
24
+ key={value}
25
+ appearance={format.resultType === value ? 'primary' : 'secondary'}
26
+ size="small"
27
+ onClick={() => onChange(value)}
28
+ >
29
+ {label}
30
+ </Button>
31
+ ))}
32
+ </Flex>
33
+ </Fragment>
34
+ );
@@ -0,0 +1,29 @@
1
+ import { FC, Fragment } from 'react';
2
+ import { CALCULATED_FIELD_TYPES } from '../../constants';
3
+ import { FieldTypeOption } from '../../interface/types';
4
+ import { FieldType } from './field-type';
5
+
6
+ interface CalculatedFieldTypeListProps {
7
+ onDragStart(fieldOption: FieldTypeOption): void;
8
+ onDragEnd(): void;
9
+ }
10
+ export const CalculatedFieldTypeList: FC<CalculatedFieldTypeListProps> = ({
11
+ onDragEnd,
12
+ onDragStart,
13
+ }) => {
14
+ return (
15
+ <Fragment>
16
+ {CALCULATED_FIELD_TYPES.map(fieldOption => {
17
+ const key = `${fieldOption.type}-${fieldOption.path ?? fieldOption.label}`;
18
+ return (
19
+ <FieldType
20
+ key={key}
21
+ label={fieldOption.label}
22
+ onDragEnd={onDragEnd}
23
+ onDragStart={() => onDragStart(fieldOption)}
24
+ />
25
+ );
26
+ })}
27
+ </Fragment>
28
+ );
29
+ };
@@ -1,10 +1,9 @@
1
- import { Flex, Text } from '@servicetitan/anvil2';
2
- import { FC, Fragment, useMemo } from 'react';
1
+ import { Flex, SearchField, Text } from '@servicetitan/anvil2';
2
+ import { FC, Fragment, useMemo, useState } from 'react';
3
3
  import { DataModelFieldGroup, FieldTypeOption } from '../../interface/types';
4
4
  import { FieldType } from './field-type';
5
5
 
6
6
  interface DataModelFieldTypeListProps {
7
- searchText?: string;
8
7
  dataModelGroups?: DataModelFieldGroup[];
9
8
  onDragStart(fieldOption: FieldTypeOption): void;
10
9
  onDragEnd(): void;
@@ -14,8 +13,9 @@ export const DataModelFieldTypeList: FC<DataModelFieldTypeListProps> = ({
14
13
  dataModelGroups = [],
15
14
  onDragEnd,
16
15
  onDragStart,
17
- searchText,
18
16
  }) => {
17
+ const [searchText, setSearchText] = useState('');
18
+
19
19
  const searchedFieldGroups = useMemo(() => {
20
20
  if (!searchText) {
21
21
  return dataModelGroups;
@@ -33,6 +33,12 @@ export const DataModelFieldTypeList: FC<DataModelFieldTypeListProps> = ({
33
33
  }, [dataModelGroups, searchText]);
34
34
  return (
35
35
  <Fragment>
36
+ <SearchField
37
+ placeholder="Search"
38
+ size="small"
39
+ className="dte-field-sidebar-search"
40
+ onChange={e => setSearchText(e.target.value ?? '')}
41
+ />
36
42
  {searchedFieldGroups.map(group => (
37
43
  <div key={group.groupName} className="dte-field-type-group">
38
44
  <Flex alignItems="center" gap={1} className="dte-field-type-group-header">
@@ -0,0 +1,36 @@
1
+ import { Flex, Icon, Text } from '@servicetitan/anvil2';
2
+ import { FC } from 'react';
3
+ import { MenuGroupModel } from '../../constants/menu-group';
4
+ import { FieldTypeEnum } from '../../interface/types';
5
+
6
+ interface FieldMenuGroupProps {
7
+ menuGroups: MenuGroupModel[];
8
+ activeFieldKey: FieldTypeEnum;
9
+ onClickMenu(key: FieldTypeEnum): void;
10
+ }
11
+
12
+ export const FieldMenuGroup: FC<FieldMenuGroupProps> = ({
13
+ activeFieldKey,
14
+ menuGroups,
15
+ onClickMenu,
16
+ }) => {
17
+ return (
18
+ <Flex className="dte-field-sidebar-menu" shrink={0} direction="column" gap="3">
19
+ {menuGroups.map(({ key, label, svgIcon }) => (
20
+ <Flex
21
+ key={key}
22
+ alignItems="center"
23
+ justifyContent="center"
24
+ direction="column"
25
+ className={`dte-field-sidebar-menu-item cursor-pointer ${key === activeFieldKey ? '--active' : ''}`}
26
+ onClick={() => onClickMenu(key)}
27
+ >
28
+ <Icon svg={svgIcon} size="large" className="dte-field-sidebar-menu-item-icon" />
29
+ <Text variant="body" size="small" className="dte-field-sidebar-menu-item-text">
30
+ {label}
31
+ </Text>
32
+ </Flex>
33
+ ))}
34
+ </Flex>
35
+ );
36
+ };