@servicetitan/dte-pdf-editor 1.92.1 → 1.92.3

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 (43) hide show
  1. package/dist/components/field-config-panel/field-sidebar.d.ts.map +1 -1
  2. package/dist/components/field-config-panel/field-sidebar.js +8 -4
  3. package/dist/components/field-config-panel/field-sidebar.js.map +1 -1
  4. package/dist/components/field-sidebar/data-model-field-type-list.d.ts +15 -1
  5. package/dist/components/field-sidebar/data-model-field-type-list.d.ts.map +1 -1
  6. package/dist/components/field-sidebar/data-model-field-type-list.js +54 -10
  7. package/dist/components/field-sidebar/data-model-field-type-list.js.map +1 -1
  8. package/dist/components/field-sidebar/field-sidebar.d.ts +7 -0
  9. package/dist/components/field-sidebar/field-sidebar.d.ts.map +1 -1
  10. package/dist/components/field-sidebar/field-sidebar.js +8 -11
  11. package/dist/components/field-sidebar/field-sidebar.js.map +1 -1
  12. package/dist/components/field-sidebar/form-fields-type-list.d.ts +28 -5
  13. package/dist/components/field-sidebar/form-fields-type-list.d.ts.map +1 -1
  14. package/dist/components/field-sidebar/form-fields-type-list.js +27 -6
  15. package/dist/components/field-sidebar/form-fields-type-list.js.map +1 -1
  16. package/dist/components/pdf-editor/pdf-editor.d.ts.map +1 -1
  17. package/dist/components/pdf-editor/pdf-editor.js +3 -2
  18. package/dist/components/pdf-editor/pdf-editor.js.map +1 -1
  19. package/dist/components/pdf-view/pdf-view.js +1 -1
  20. package/dist/components/pdf-view/pdf-view.js.map +1 -1
  21. package/dist/constants/menu-group.d.ts +5 -0
  22. package/dist/constants/menu-group.d.ts.map +1 -1
  23. package/dist/constants/menu-group.js +5 -2
  24. package/dist/constants/menu-group.js.map +1 -1
  25. package/dist/hooks/useFormFields.d.ts +3 -1
  26. package/dist/hooks/useFormFields.d.ts.map +1 -1
  27. package/dist/hooks/useFormFields.js +20 -3
  28. package/dist/hooks/useFormFields.js.map +1 -1
  29. package/dist/styles/field-type.css +5 -0
  30. package/dist/styles/formula-modal.css +17 -0
  31. package/dist/styles/pdf-canvas.css +5 -5
  32. package/package.json +1 -1
  33. package/src/components/field-config-panel/field-sidebar.tsx +24 -8
  34. package/src/components/field-sidebar/data-model-field-type-list.tsx +143 -29
  35. package/src/components/field-sidebar/field-sidebar.tsx +19 -18
  36. package/src/components/field-sidebar/form-fields-type-list.tsx +137 -95
  37. package/src/components/pdf-editor/pdf-editor.tsx +4 -3
  38. package/src/components/pdf-view/pdf-view.tsx +1 -1
  39. package/src/constants/menu-group.ts +5 -2
  40. package/src/hooks/useFormFields.ts +24 -2
  41. package/src/styles/field-type.css +5 -0
  42. package/src/styles/formula-modal.css +17 -0
  43. package/src/styles/pdf-canvas.css +5 -5
@@ -1,4 +1,4 @@
1
- import { Button, Flex, Text } from '@servicetitan/anvil2';
1
+ import { Button, Flex, Skeleton, Text } from '@servicetitan/anvil2';
2
2
  import IconArrowBack from '@servicetitan/anvil2/assets/icons/material/round/keyboard_arrow_left.svg';
3
3
  import IconArrowForward from '@servicetitan/anvil2/assets/icons/material/round/keyboard_arrow_right.svg';
4
4
  import { FC, Fragment } from 'react';
@@ -6,112 +6,154 @@ import { type FieldTypeOption, type FormInfo } from '../../interface/types';
6
6
  import { getFieldSampleData } from '../../utils';
7
7
  import { FieldType } from './field-type';
8
8
 
9
- export interface FormFieldsTypeListProps {
9
+ /** Heading of the Forms group inside the Merge Tags tab. */
10
+ export const FORMS_GROUP_NAME = 'Forms';
11
+
12
+ /** A form's field count is unknown until it answers, so the placeholder settles on a short list. */
13
+ const FIELD_SKELETON_ROWS = 4;
14
+
15
+ /** Placeholder rows, shaped like the real ones, for a list whose contents are still in flight. */
16
+ const SkeletonRows: FC<{ count: number; className: string }> = ({ className, count }) => {
17
+ return (
18
+ <Fragment>
19
+ {Array.from({ length: count }, (_, index) => (
20
+ <div key={index} className={`${className} --skeleton`} aria-hidden>
21
+ <Skeleton.Text />
22
+ </div>
23
+ ))}
24
+ </Fragment>
25
+ );
26
+ };
27
+
28
+ export interface FormsListProps {
10
29
  forms: FormInfo[];
11
- selectedForm: FormInfo | null;
30
+ /**
31
+ * Rows to draw as placeholders instead of the list. Forms are only listed once they are known
32
+ * to have fields, so until the fields arrive there is nothing truthful to show.
33
+ */
34
+ loadingCount?: number;
35
+ /** Rendered when the (possibly search-filtered) list is empty. */
36
+ emptyText?: string;
37
+ onSelectForm(form: FormInfo): void;
38
+ }
39
+
40
+ /**
41
+ * Root level of the Forms group: one clickable row per form. Selecting a form hands off to
42
+ * `FormFieldsPanel`, which lists that form's draggable fields.
43
+ */
44
+ export const FormsList: FC<FormsListProps> = ({
45
+ emptyText = 'No forms configured.',
46
+ forms,
47
+ loadingCount = 0,
48
+ onSelectForm,
49
+ }) => {
50
+ return (
51
+ <div className="dte-field-type-group">
52
+ <Flex alignItems="center" gap={1} className="dte-field-type-group-header">
53
+ <Text variant="headline" el="h6" size="small">
54
+ {FORMS_GROUP_NAME}
55
+ </Text>
56
+ </Flex>
57
+ {loadingCount > 0 ? (
58
+ <SkeletonRows count={loadingCount} className="dte-formula-field-list-item" />
59
+ ) : forms.length > 0 ? (
60
+ <ul className="dte-formula-field-list" role="listbox" aria-label={FORMS_GROUP_NAME}>
61
+ {forms.map(form => (
62
+ <li
63
+ key={form.id}
64
+ role="option"
65
+ className="dte-formula-field-list-item"
66
+ onMouseDown={e => {
67
+ e.preventDefault();
68
+ onSelectForm(form);
69
+ }}
70
+ >
71
+ <Flex justifyContent="space-between" alignItems="center">
72
+ {/*
73
+ * Same Text as the data model rows use (via FieldType): a bare
74
+ * span would inherit the host page's font instead of Anvil2's,
75
+ * so the Forms rows would not match the groups above them.
76
+ */}
77
+ <Text size="small" className="dte-text-ellipsis" title={form.name}>
78
+ {form.name}
79
+ </Text>
80
+ <IconArrowForward />
81
+ </Flex>
82
+ </li>
83
+ ))}
84
+ </ul>
85
+ ) : (
86
+ <Text size="small" subdued variant="body">
87
+ {emptyText}
88
+ </Text>
89
+ )}
90
+ </div>
91
+ );
92
+ };
93
+
94
+ export interface FormFieldsPanelProps {
95
+ form: FormInfo;
12
96
  fieldOptions: FieldTypeOption[];
13
- isLoadingFormFields?: boolean;
14
- onSelectForm(form: FormInfo | null): void;
97
+ isLoading?: boolean;
98
+ /** Rendered when the form has fields but none match the current search. */
99
+ hasHiddenFields?: boolean;
100
+ onBack(): void;
15
101
  onDragEnd(): void;
16
102
  onDragStart(fieldOption: FieldTypeOption): void;
17
103
  }
18
104
 
19
- export const FormFieldsTypeList: FC<FormFieldsTypeListProps> = ({
105
+ /**
106
+ * Detail level of the Forms group: a back button naming the form, then its fields as the same
107
+ * draggable rows the data model groups use, so a form field drops onto the PDF like any merge tag.
108
+ */
109
+ export const FormFieldsPanel: FC<FormFieldsPanelProps> = ({
20
110
  fieldOptions,
21
- forms,
22
- isLoadingFormFields = false,
111
+ form,
112
+ hasHiddenFields = false,
113
+ isLoading = false,
114
+ onBack,
23
115
  onDragEnd,
24
116
  onDragStart,
25
- onSelectForm,
26
- selectedForm,
27
117
  }) => {
28
118
  return (
29
- <Flex direction="column" gap={3} flex={1} style={{ minHeight: 0 }}>
30
- <div className="dte-formula-sidebar-slide-outer">
31
- <div
32
- className={`dte-formula-sidebar-slide-track${selectedForm ? ' --detail' : ''}`}
119
+ <Fragment>
120
+ <Button
121
+ style={{ width: '100%' }}
122
+ appearance="ghost"
123
+ size="small"
124
+ icon={IconArrowBack}
125
+ title={form.name}
126
+ onClick={onBack}
127
+ >
128
+ <Text
129
+ size="medium"
130
+ className="dte-text-ellipsis"
131
+ style={{ fontWeight: 'bold', width: '100%' }}
33
132
  >
34
- <div className="dte-formula-sidebar-slide-panel">
35
- {forms.length > 0 ? (
36
- <ul
37
- className="dte-formula-field-list"
38
- role="listbox"
39
- aria-label="Forms"
40
- >
41
- {forms.map(form => (
42
- <li
43
- key={form.id}
44
- role="option"
45
- className="dte-formula-field-list-item"
46
- onMouseDown={e => {
47
- e.preventDefault();
48
- onSelectForm(form);
49
- }}
50
- >
51
- <Flex justifyContent="space-between" alignItems="center">
52
- <span className="dte-text-ellipsis" title={form.name}>
53
- {form.name}
54
- </span>
55
- <IconArrowForward />
56
- </Flex>
57
- </li>
58
- ))}
59
- </ul>
60
- ) : (
61
- <Text size="small" subdued variant="body">
62
- No forms configured.
63
- </Text>
64
- )}
65
- </div>
66
-
67
- <div className="dte-formula-sidebar-slide-panel --form-fields">
68
- {selectedForm ? (
69
- <Fragment>
70
- <Button
71
- style={{ width: '100%' }}
72
- appearance="ghost"
73
- size="small"
74
- icon={IconArrowBack}
75
- title={selectedForm.name}
76
- onClick={() => onSelectForm(null)}
77
- >
78
- <Text
79
- size="medium"
80
- className="dte-text-ellipsis"
81
- style={{ fontWeight: 'bold', width: '100%' }}
82
- >
83
- {selectedForm.name}
84
- </Text>
85
- </Button>
86
- <div className="dte-field-type-group">
87
- {isLoadingFormFields ? (
88
- <Text size="small" subdued variant="body">
89
- Loading fields…
90
- </Text>
91
- ) : fieldOptions.length === 0 ? (
92
- <Text size="small" subdued variant="body">
93
- No fields on this form.
94
- </Text>
95
- ) : (
96
- fieldOptions.map(option => (
97
- <FieldType
98
- key={option.path}
99
- label={option.label}
100
- sampleData={getFieldSampleData(
101
- option.sampleData,
102
- option.fieldType,
103
- )}
104
- onDragEnd={onDragEnd}
105
- onDragStart={() => onDragStart(option)}
106
- />
107
- ))
108
- )}
109
- </div>
110
- </Fragment>
111
- ) : null}
112
- </div>
113
- </div>
133
+ {form.name}
134
+ </Text>
135
+ </Button>
136
+ <div className="dte-field-type-group">
137
+ {isLoading ? (
138
+ <SkeletonRows count={FIELD_SKELETON_ROWS} className="dte-field-type-item" />
139
+ ) : fieldOptions.length === 0 ? (
140
+ <Text size="small" subdued variant="body">
141
+ {hasHiddenFields
142
+ ? 'No fields match the search.'
143
+ : 'No fields on this form.'}
144
+ </Text>
145
+ ) : (
146
+ fieldOptions.map(option => (
147
+ <FieldType
148
+ key={option.path}
149
+ label={option.label}
150
+ sampleData={getFieldSampleData(option.sampleData, option.fieldType)}
151
+ onDragEnd={onDragEnd}
152
+ onDragStart={() => onDragStart(option)}
153
+ />
154
+ ))
155
+ )}
114
156
  </div>
115
- </Flex>
157
+ </Fragment>
116
158
  );
117
159
  };
@@ -59,7 +59,7 @@ export const PdfEditor: FC<PdfEditorProps> = ({
59
59
  const pdfContainerRef = useRef<HTMLDivElement>(null);
60
60
  const pdfWrapperRef = useRef<HTMLDivElement>(null);
61
61
  const pdfFieldSelection = usePdfFieldSelection(fields, onFieldsChange);
62
- const formFields = useFormFields(fields, onFormSelect);
62
+ const formFields = useFormFields(fields, onFormSelect, forms);
63
63
  const pdfFieldDnd = usePdfFieldDnD({
64
64
  fields,
65
65
  recipients,
@@ -90,7 +90,7 @@ export const PdfEditor: FC<PdfEditorProps> = ({
90
90
  dataModel={dataModel}
91
91
  documentFields={fields}
92
92
  formFieldsByFormId={formFields.formFieldsByFormId}
93
- forms={forms ?? []}
93
+ forms={formFields.formsWithFields}
94
94
  formulaLoadingFormId={formFields.formulaLoadingFormId}
95
95
  selectedField={pdfFieldSelection.selectedField}
96
96
  onFieldConfigChange={pdfFieldSelection.updateField}
@@ -108,7 +108,8 @@ export const PdfEditor: FC<PdfEditorProps> = ({
108
108
  hideFields={hideFields}
109
109
  dataModelGroups={dataModelGroups}
110
110
  formFieldOptions={formFields.formFieldOptions}
111
- forms={forms}
111
+ forms={formFields.formsWithFields}
112
+ loadingFormsCount={formFields.isLoadingForms ? (forms?.length ?? 0) : 0}
112
113
  isLoadingFormFields={formFields.isLoading}
113
114
  onDragStart={pdfFieldDnd.handleDragStart}
114
115
  onDragEnd={pdfFieldDnd.handleDragEnd}
@@ -187,7 +187,7 @@ export const PdfView: FC<PdfViewProps> = ({
187
187
  return (
188
188
  <Flex
189
189
  direction="column"
190
- style={{ position: 'relative', minWidth: 0 }}
190
+ style={{ position: 'relative', minWidth: 0, height: '100%' }}
191
191
  className="dte-pdf-editor-theme"
192
192
  ref={contentContainerRef}
193
193
  >
@@ -1,6 +1,5 @@
1
1
  import IconBorderColor from '@servicetitan/anvil2/assets/icons/material/round/border_color.svg';
2
2
  import IconCalculate from '@servicetitan/anvil2/assets/icons/material/round/calculate.svg';
3
- import IconFactCheck from '@servicetitan/anvil2/assets/icons/material/round/fact_check.svg';
4
3
  import IconPhotoSizeSelectSmall from '@servicetitan/anvil2/assets/icons/material/round/photo_size_select_small.svg';
5
4
  import IconCommercial from '@servicetitan/anvil2/assets/icons/st/commercial.svg';
6
5
  import IconEstimate from '@servicetitan/anvil2/assets/icons/st/estimate.svg';
@@ -12,6 +11,11 @@ export interface MenuGroupModel {
12
11
  key: FieldTypeEnum;
13
12
  }
14
13
 
14
+ /**
15
+ * Sidebar tabs. Forms (`FieldTypeEnum.forms`) has no tab of its own: form fields are offered as a
16
+ * group inside Merge Tags, next to the data model groups. `hideFields: [FieldTypeEnum.forms]` still
17
+ * hides that group.
18
+ */
15
19
  export const menuGroups: MenuGroupModel[] = [
16
20
  { svgIcon: IconEstimate, label: 'Merge Tags', key: FieldTypeEnum.dataModel },
17
21
  { svgIcon: IconBorderColor, label: 'E-Sign', key: FieldTypeEnum.eSign },
@@ -30,5 +34,4 @@ export const menuGroups: MenuGroupModel[] = [
30
34
  label: 'Generic Fields',
31
35
  key: FieldTypeEnum.generic,
32
36
  },
33
- { svgIcon: IconFactCheck, label: 'Forms', key: FieldTypeEnum.forms },
34
37
  ];
@@ -10,6 +10,8 @@ import { formFieldInfoToFieldTypeOption } from '../utils';
10
10
 
11
11
  interface UseFormFieldsResult {
12
12
  isLoading: boolean;
13
+ isLoadingForms: boolean;
14
+ formsWithFields: FormInfo[];
13
15
  formFieldsByFormId: FormFieldsByFormIdI;
14
16
  formulaLoadingFormId: null | number;
15
17
  formFieldOptions: FieldTypeOption[];
@@ -18,12 +20,15 @@ interface UseFormFieldsResult {
18
20
  requestFormFieldsById: (formId: number) => void;
19
21
  }
20
22
 
23
+ const EMPTY_FORMS: FormInfo[] = [];
24
+
21
25
  export const useFormFields = (
22
26
  fields: PdfField[],
23
27
  onFormSelect?: (
24
28
  formIds: number[],
25
29
  sendFormFields: (formFieldsByFormId: FormFieldsByFormIdI) => void,
26
30
  ) => void,
31
+ forms: FormInfo[] = EMPTY_FORMS,
27
32
  ): UseFormFieldsResult => {
28
33
  const [formFieldsByFormId, setFormFieldsByFormId] = useState<FormFieldsByFormIdI>({});
29
34
  const [formulaLoadingFormId, setFormulaLoadingFormId] = useState<number | null>(null);
@@ -40,7 +45,13 @@ export const useFormFields = (
40
45
  return;
41
46
  }
42
47
 
43
- const initialFormIds = new Set<number>();
48
+ /*
49
+ * Every form is asked for up front, not just the ones a formula already references: a form
50
+ * with no fields has nothing to offer and is dropped from the list (`formsWithFields`), and
51
+ * that cannot be decided without its fields. They go out as one batched call, and the reply
52
+ * is cached, so opening a form afterwards costs nothing.
53
+ */
54
+ const initialFormIds = new Set<number>(forms.map(form => form.id));
44
55
  fields.forEach(field => {
45
56
  if (field.type === FieldTypeEnum.calculated) {
46
57
  if (field?.formula?.tokens.length) {
@@ -90,7 +101,7 @@ export const useFormFields = (
90
101
  cancelled = true;
91
102
  cancelAnimationFrame(rafId);
92
103
  };
93
- }, [fields, onFormSelect]);
104
+ }, [fields, forms, onFormSelect]);
94
105
 
95
106
  const formFieldOptions = useMemo(() => {
96
107
  if (!selectedForm) {
@@ -143,10 +154,21 @@ export const useFormFields = (
143
154
  [formFieldsByFormId, onFormSelect],
144
155
  );
145
156
 
157
+ /*
158
+ * A form is offered only once it is known to have something to drag out of it. Until its reply
159
+ * lands it is neither listed nor counted as empty — `isLoadingForms` stands in for it.
160
+ */
161
+ const formsWithFields = useMemo(() => {
162
+ return forms.filter(form => !!formFieldsByFormId[form.id]?.length);
163
+ }, [forms, formFieldsByFormId]);
164
+
146
165
  const isLoading = selectedForm != null && formFieldsLoadingFormIds[selectedForm.id];
166
+ const isLoadingForms = forms.some(form => !!formFieldsLoadingFormIds[form.id]);
147
167
 
148
168
  return {
149
169
  isLoading,
170
+ isLoadingForms,
171
+ formsWithFields,
150
172
  formFieldsByFormId,
151
173
  formulaLoadingFormId,
152
174
  formFieldOptions,
@@ -16,6 +16,11 @@
16
16
  box-shadow: 0 0 2px 0 var(--border-color-active);
17
17
  }
18
18
 
19
+ .dte-field-type-item.--skeleton {
20
+ cursor: default;
21
+ pointer-events: none;
22
+ }
23
+
19
24
  .dte-field-type-items-wrapper {
20
25
  &.--grid-2 {
21
26
  display: grid;
@@ -69,6 +69,18 @@
69
69
  gap: 0;
70
70
  }
71
71
 
72
+ /*
73
+ * The panels share one scroller, so the list — always the taller of the two — would otherwise keep
74
+ * driving the scroll height while the fields panel is shown, leaving the fields followed by a
75
+ * screenful of nothing. Both bounds are needed: the min-content floor above keeps a max-height on
76
+ * its own from taking effect.
77
+ */
78
+ .dte-formula-sidebar-slide-track.--detail .dte-formula-sidebar-slide-panel:not(.--form-fields) {
79
+ min-height: 0;
80
+ max-height: 0;
81
+ overflow: hidden;
82
+ }
83
+
72
84
  .dte-formula-sidebar-forms-heading {
73
85
  flex-shrink: 0;
74
86
  }
@@ -94,6 +106,11 @@
94
106
  white-space: nowrap;
95
107
  }
96
108
 
109
+ .dte-formula-field-list-item.--skeleton {
110
+ cursor: default;
111
+ pointer-events: none;
112
+ }
113
+
97
114
  .dte-formula-field-list-item:hover,
98
115
  .dte-formula-field-list-item.--highlight {
99
116
  color: var(--color-neutral-100, #e8e8e8);
@@ -20,7 +20,7 @@
20
20
  background-color: var(--bg-color-light, #ffffff);
21
21
  border: 1px solid var(--border-color, #e0e0e0);
22
22
  border-radius: var(--radius-2, 8px);
23
- position: fixed;
23
+ position: absolute;
24
24
  z-index: 2;
25
25
 
26
26
  &.--vertical {
@@ -28,14 +28,14 @@
28
28
  }
29
29
 
30
30
  &.--right-center {
31
- right: 24px;
31
+ right: 0;
32
32
  top: 50%;
33
- transform: translateY(-50%);
33
+ transform: translate(100%, -50%);
34
34
  }
35
35
 
36
36
  &.--right-bottom {
37
- right: 24px;
38
- bottom: 24px;
37
+ bottom: 12px;
38
+ right: 12px;
39
39
  }
40
40
  }
41
41