@servicetitan/dte-pdf-editor 1.51.0 → 1.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +67 -51
  2. package/dist/components/display-conditions/condition-row.d.ts.map +1 -1
  3. package/dist/components/display-conditions/condition-row.js +33 -11
  4. package/dist/components/display-conditions/condition-row.js.map +1 -1
  5. package/dist/components/field-config-panel/field-config-panel-overlay.d.ts +1 -0
  6. package/dist/components/field-config-panel/field-config-panel-overlay.d.ts.map +1 -1
  7. package/dist/components/field-config-panel/field-config-panel-overlay.js +2 -2
  8. package/dist/components/field-config-panel/field-config-panel-overlay.js.map +1 -1
  9. package/dist/components/field-config-panel/field-config-panel.d.ts +1 -0
  10. package/dist/components/field-config-panel/field-config-panel.d.ts.map +1 -1
  11. package/dist/components/field-config-panel/field-config-panel.js +38 -15
  12. package/dist/components/field-config-panel/field-config-panel.js.map +1 -1
  13. package/dist/components/pdf-editor/pdf-editor.d.ts.map +1 -1
  14. package/dist/components/pdf-editor/pdf-editor.js +1 -1
  15. package/dist/components/pdf-editor/pdf-editor.js.map +1 -1
  16. package/dist/components/pdf-view/pdf-view-fillable.d.ts +2 -0
  17. package/dist/components/pdf-view/pdf-view-fillable.d.ts.map +1 -1
  18. package/dist/components/pdf-view/pdf-view-fillable.js +14 -10
  19. package/dist/components/pdf-view/pdf-view-fillable.js.map +1 -1
  20. package/dist/components/pdf-view/pdf-view.d.ts +11 -0
  21. package/dist/components/pdf-view/pdf-view.d.ts.map +1 -1
  22. package/dist/components/pdf-view/pdf-view.js +2 -2
  23. package/dist/components/pdf-view/pdf-view.js.map +1 -1
  24. package/dist/hooks/usePdfFieldDnD.d.ts.map +1 -1
  25. package/dist/hooks/usePdfFieldDnD.js +5 -0
  26. package/dist/hooks/usePdfFieldDnD.js.map +1 -1
  27. package/dist/hooks/usePdfFieldSelection.d.ts +1 -0
  28. package/dist/hooks/usePdfFieldSelection.d.ts.map +1 -1
  29. package/dist/hooks/usePdfFieldSelection.js +4 -0
  30. package/dist/hooks/usePdfFieldSelection.js.map +1 -1
  31. package/dist/interface/types.d.ts +16 -0
  32. package/dist/interface/types.d.ts.map +1 -1
  33. package/dist/interface/types.js +5 -0
  34. package/dist/interface/types.js.map +1 -1
  35. package/dist/utils/conditions/schema-data-points.utils.d.ts +1 -0
  36. package/dist/utils/conditions/schema-data-points.utils.d.ts.map +1 -1
  37. package/dist/utils/conditions/schema-data-points.utils.js +28 -5
  38. package/dist/utils/conditions/schema-data-points.utils.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/components/display-conditions/condition-row.tsx +80 -21
  41. package/src/components/field-config-panel/field-config-panel-overlay.tsx +6 -0
  42. package/src/components/field-config-panel/field-config-panel.tsx +61 -12
  43. package/src/components/pdf-editor/pdf-editor.tsx +1 -0
  44. package/src/components/pdf-view/pdf-view-fillable.tsx +18 -3
  45. package/src/components/pdf-view/pdf-view.tsx +8 -1
  46. package/src/hooks/usePdfFieldDnD.ts +5 -0
  47. package/src/hooks/usePdfFieldSelection.ts +8 -0
  48. package/src/interface/types.ts +15 -0
  49. package/src/utils/conditions/__tests__/schema-data-points.utils.test.ts +35 -0
  50. package/src/utils/conditions/schema-data-points.utils.ts +36 -5
@@ -20,6 +20,10 @@ interface FieldConfigPanelOverlayProps {
20
20
  recipients?: RecipientInfo[];
21
21
  hideConditionalLogic?: boolean;
22
22
  onFieldConfigChange(updates: Partial<PdfField>): void;
23
+ onMatchingFieldsConfigChange?(
24
+ predicate: (field: PdfField) => boolean,
25
+ updates: Partial<PdfField>,
26
+ ): void;
23
27
  onDeleteField(): void;
24
28
  onDeselectField?(): void;
25
29
  onRequestFormFields(formId: number): void;
@@ -35,6 +39,7 @@ export const FieldConfigPanelOverlay: FC<FieldConfigPanelOverlayProps> = ({
35
39
  onDeleteField,
36
40
  onDeselectField,
37
41
  onFieldConfigChange,
42
+ onMatchingFieldsConfigChange,
38
43
  onRequestFormFields,
39
44
  recipients,
40
45
  selectedField,
@@ -67,6 +72,7 @@ export const FieldConfigPanelOverlay: FC<FieldConfigPanelOverlayProps> = ({
67
72
  formulaLoadingFormId={formulaLoadingFormId}
68
73
  recipients={recipients}
69
74
  onFieldConfigChange={onFieldConfigChange}
75
+ onMatchingFieldsConfigChange={onMatchingFieldsConfigChange}
70
76
  onDeleteField={onDeleteField}
71
77
  hideConditionalLogic={hideConditionalLogic}
72
78
  onRequestFormFields={onRequestFormFields}
@@ -14,6 +14,7 @@ import {
14
14
  generateESignPath,
15
15
  generateFillablePath,
16
16
  getFieldFontSize,
17
+ parseFillablePathName,
17
18
  supportsFieldFontSize,
18
19
  } from '../../utils';
19
20
  import { DisplayCondition } from '../display-conditions/display-condition';
@@ -31,6 +32,10 @@ interface FieldConfigPanelProps {
31
32
  hideConditionalLogic?: boolean;
32
33
  onDeleteField(): void;
33
34
  onFieldConfigChange(updates: Partial<PdfField>): void;
35
+ onMatchingFieldsConfigChange?(
36
+ predicate: (field: PdfField) => boolean,
37
+ updates: Partial<PdfField>,
38
+ ): void;
34
39
  onRequestFormFields(formId: number): void;
35
40
  }
36
41
 
@@ -44,6 +49,7 @@ export const FieldConfigPanel: FC<FieldConfigPanelProps> = ({
44
49
  hideConditionalLogic = false,
45
50
  onDeleteField,
46
51
  onFieldConfigChange,
52
+ onMatchingFieldsConfigChange,
47
53
  onRequestFormFields,
48
54
  recipients = [],
49
55
  }) => {
@@ -61,20 +67,42 @@ export const FieldConfigPanel: FC<FieldConfigPanelProps> = ({
61
67
  [recipientOptions, field.recipient],
62
68
  );
63
69
 
70
+ const isRadioGroupSibling = (documentField: PdfField) =>
71
+ documentField.subType === 'radio' && documentField.path === field.path;
72
+
64
73
  const handleRecipientChange = (item: { id: string; name: string } | null) => {
65
- if (item) {
66
- let path = field.path;
67
- if (field.type === FieldTypeEnum.eSign) {
68
- path = generateESignPath(item.id, field.subType as ESignFieldType);
69
- }
70
- if (field.type === FieldTypeEnum.fillable) {
71
- path = generateFillablePath(item.id, field.id);
72
- }
73
- onFieldConfigChange({
74
- recipient: item.id,
75
- path,
76
- });
74
+ if (!item) {
75
+ return;
76
+ }
77
+
78
+ let path = field.path;
79
+ if (field.type === FieldTypeEnum.eSign) {
80
+ path = generateESignPath(item.id, field.subType as ESignFieldType);
81
+ }
82
+ if (field.type === FieldTypeEnum.fillable) {
83
+ const pathName =
84
+ field.subType === 'radio' && field.path
85
+ ? parseFillablePathName(field.path)
86
+ : field.id;
87
+ path = generateFillablePath(item.id, pathName);
88
+ }
89
+
90
+ const updates = {
91
+ recipient: item.id,
92
+ path,
93
+ };
94
+
95
+ if (
96
+ field.type === FieldTypeEnum.fillable &&
97
+ field.subType === 'radio' &&
98
+ field.path &&
99
+ onMatchingFieldsConfigChange
100
+ ) {
101
+ onMatchingFieldsConfigChange(isRadioGroupSibling, updates);
102
+ return;
77
103
  }
104
+
105
+ onFieldConfigChange(updates);
78
106
  };
79
107
 
80
108
  const selectedESignFieldType = useMemo(
@@ -104,6 +132,19 @@ export const FieldConfigPanel: FC<FieldConfigPanelProps> = ({
104
132
  }
105
133
  };
106
134
 
135
+ const handleRadioGroupNameChange = (groupName: string) => {
136
+ if (!field.path) {
137
+ return;
138
+ }
139
+
140
+ if (onMatchingFieldsConfigChange) {
141
+ onMatchingFieldsConfigChange(isRadioGroupSibling, { groupName });
142
+ return;
143
+ }
144
+
145
+ onFieldConfigChange({ groupName });
146
+ };
147
+
107
148
  return (
108
149
  <Flex direction="column" gap="4">
109
150
  {[
@@ -123,6 +164,14 @@ export const FieldConfigPanel: FC<FieldConfigPanelProps> = ({
123
164
  }
124
165
  />
125
166
  )}
167
+ {field.type === FieldTypeEnum.fillable && field.subType === 'radio' && (
168
+ <TextField
169
+ required
170
+ label="Radio Group Name"
171
+ value={field.groupName!}
172
+ onChange={e => handleRadioGroupNameChange(e.target.value)}
173
+ />
174
+ )}
126
175
  {(field.type === FieldTypeEnum.fillable || field.type === FieldTypeEnum.eSign) && (
127
176
  <Combobox
128
177
  items={recipientOptions}
@@ -87,6 +87,7 @@ export const PdfEditor: FC<PdfEditorProps> = ({
87
87
  formulaLoadingFormId={formFields.formulaLoadingFormId}
88
88
  selectedField={pdfFieldSelection.selectedField}
89
89
  onFieldConfigChange={pdfFieldSelection.updateField}
90
+ onMatchingFieldsConfigChange={pdfFieldSelection.updateMatchingFields}
90
91
  onDeleteField={pdfFieldSelection.deleteSelectedField}
91
92
  recipients={recipients}
92
93
  onDeselectField={pdfFieldSelection.deselectField}
@@ -5,6 +5,7 @@ import { dateToUtcZero, getFieldPlaceholderText, toDateInputValue } from '../../
5
5
  interface PdfViewFillableProps {
6
6
  field: PdfField;
7
7
  data?: DataModelValues;
8
+ readOnly?: boolean;
8
9
  fillingBy?: string[];
9
10
  viewMode?: PdfViewMode;
10
11
  onDataChange?(changedData: DataChangePayload, field: PdfField): void;
@@ -14,6 +15,7 @@ interface FillableTextInputProps {
14
15
  field: PdfField;
15
16
  value: string;
16
17
  disabled: boolean;
18
+ readOnly?: boolean;
17
19
  onChange(value: string): void;
18
20
  }
19
21
 
@@ -29,10 +31,12 @@ export const FillableTextInput: FC<FillableTextInputProps> = ({
29
31
  disabled,
30
32
  field,
31
33
  onChange,
34
+ readOnly,
32
35
  value,
33
36
  }) => {
34
37
  return (
35
38
  <input
39
+ readOnly={readOnly}
36
40
  type="text"
37
41
  style={fillableTextStyles}
38
42
  disabled={disabled}
@@ -49,10 +53,12 @@ export const FillableTextareaInput: FC<FillableTextInputProps> = ({
49
53
  disabled,
50
54
  field,
51
55
  onChange,
56
+ readOnly,
52
57
  value,
53
58
  }) => {
54
59
  return (
55
60
  <textarea
61
+ readOnly={readOnly}
56
62
  style={{ ...fillableTextStyles, resize: 'none' }}
57
63
  disabled={disabled}
58
64
  id={field.path}
@@ -69,6 +75,7 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
69
75
  field,
70
76
  fillingBy,
71
77
  onDataChange,
78
+ readOnly,
72
79
  viewMode,
73
80
  }) => {
74
81
  const resolvedValue = data?.[field.path!];
@@ -76,7 +83,12 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
76
83
  const isFillable =
77
84
  viewMode === 'preview' ? true : viewMode === 'fill' ? false : isViewByCurrentRecipient;
78
85
 
86
+ const isInputDisabled = !isFillable || !!readOnly;
87
+
79
88
  const handleDataChange = (fieldValue: DataChangePayload[string]) => {
89
+ if (readOnly) {
90
+ return;
91
+ }
80
92
  const changedData = { [field.path!]: fieldValue };
81
93
  onDataChange?.(changedData, field);
82
94
  };
@@ -101,9 +113,9 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
101
113
  }}
102
114
  type="checkbox"
103
115
  checked={resolvedValue ?? false}
104
- disabled={!isFillable}
116
+ disabled={isInputDisabled}
105
117
  name={field.path}
106
- onChange={e => handleDataChange?.(e!.target.checked)}
118
+ onChange={e => handleDataChange(e.target.checked)}
107
119
  />
108
120
  {field.label}
109
121
  </div>
@@ -131,7 +143,7 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
131
143
  type="radio"
132
144
  value={field.id}
133
145
  checked={resolvedValue === field.id}
134
- disabled={!isFillable}
146
+ disabled={isInputDisabled}
135
147
  name={field.path}
136
148
  onChange={() => handleDataChange(field.id)}
137
149
  />
@@ -144,6 +156,7 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
144
156
  const dateInputValue = toDateInputValue(resolvedValue);
145
157
  return (
146
158
  <input
159
+ readOnly={readOnly}
147
160
  type="date"
148
161
  style={{
149
162
  background: 'inherit',
@@ -168,6 +181,7 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
168
181
  if (field.subType === 'number') {
169
182
  return (
170
183
  <input
184
+ readOnly={readOnly}
171
185
  type="number"
172
186
  style={{
173
187
  background: 'inherit',
@@ -189,6 +203,7 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
189
203
  const TextComponent = field.multiline ? FillableTextareaInput : FillableTextInput;
190
204
  return (
191
205
  <TextComponent
206
+ readOnly={readOnly}
192
207
  field={field}
193
208
  value={resolvedValue ?? ''}
194
209
  disabled={!isFillable}
@@ -25,13 +25,18 @@ export interface PdfViewProps {
25
25
  data?: DataModelValues;
26
26
  pdfUrl: string;
27
27
  loading?: boolean;
28
+ /**
29
+ * If true, all fillable fields will be readonly.
30
+ * This is useful for previewing the form without allowing any changes.
31
+ */
32
+ makeFillableFieldsReadonly?: boolean;
28
33
  /**
29
34
  * UTC date strings (e.g. `'2026-03-17T00:00:00Z'`) treated as non-business days.
30
35
  * When provided, date-based calculated fields skip weekends and these holidays
31
36
  * when adding/subtracting days or computing date differences.
32
37
  */
33
38
  holidays?: string[];
34
- /*
39
+ /**
35
40
  * fillingBy defines the list of recipient names
36
41
  * who are allowed to fill this field.
37
42
  * This restriction is enforced only in 'fill' mode
@@ -54,6 +59,7 @@ export const PdfView: FC<PdfViewProps> = ({
54
59
  holidays,
55
60
  loading = false,
56
61
  loadingPlaceholder,
62
+ makeFillableFieldsReadonly = false,
57
63
  onDataChange,
58
64
  onLoadSuccess,
59
65
  pdfUrl,
@@ -122,6 +128,7 @@ export const PdfView: FC<PdfViewProps> = ({
122
128
  <PdfViewFillable
123
129
  data={viewMode === 'preview' ? previewData : data}
124
130
  field={field}
131
+ readOnly={makeFillableFieldsReadonly}
125
132
  fillingBy={fillingBy}
126
133
  viewMode={viewMode}
127
134
  onDataChange={handleDataChange}
@@ -43,6 +43,11 @@ const initializeDroppedFieldByType: Record<FieldTypeEnum, InitializeDroppedField
43
43
  recipient: recipientName,
44
44
  required: false,
45
45
  path: generateFillablePath(recipientName, uuidv4()),
46
+ ...(base.subType === 'radio'
47
+ ? {
48
+ groupName: `Radio Group ${uuidv4()}`,
49
+ }
50
+ : {}),
46
51
  ...(defaultSize && {
47
52
  width: defaultSize.width,
48
53
  height: defaultSize.height,
@@ -26,6 +26,13 @@ export const usePdfFieldSelection = (
26
26
  onFieldsChange(fields.map(f => (f.id === selectedFieldId ? { ...f, ...updates } : f)));
27
27
  };
28
28
 
29
+ const updateMatchingFields = (
30
+ predicate: (field: PdfField) => boolean,
31
+ updates: Partial<PdfField>,
32
+ ) => {
33
+ onFieldsChange(fields.map(f => (predicate(f) ? { ...f, ...updates } : f)));
34
+ };
35
+
29
36
  const deleteSelectedField = () => {
30
37
  if (!selectedFieldId) {
31
38
  return;
@@ -47,6 +54,7 @@ export const usePdfFieldSelection = (
47
54
  selectField,
48
55
  deselectField,
49
56
  updateField,
57
+ updateMatchingFields,
50
58
  deleteSelectedField,
51
59
  moveField,
52
60
  resizeField,
@@ -89,6 +89,7 @@ export interface PdfField {
89
89
  y: number;
90
90
  page: number;
91
91
  label: string;
92
+ groupName?: string;
92
93
  width: number;
93
94
  height: number;
94
95
  required?: boolean;
@@ -187,6 +188,7 @@ export interface SchemaFieldBaseOptions {
187
188
  useInCalculatedFields?: boolean;
188
189
  useInConditionals?: boolean;
189
190
  isHighlighted?: boolean;
191
+ fieldLookupSource?: string;
190
192
  }
191
193
 
192
194
  interface SchemaNodeProps {
@@ -280,12 +282,25 @@ export type ConditionOperator =
280
282
  | (typeof STRING_OPERATORS)[number]['value']
281
283
  | (typeof NUMBER_OPERATORS)[number]['value'];
282
284
 
285
+ /** Operators for radio-group fillable fields. */
286
+ export const RADIO_OPERATORS = [
287
+ { label: 'Is equal to', value: 'is_equal_to' },
288
+ { label: 'Is not equal to', value: 'is_not_equal_to' },
289
+ ] as const;
290
+
283
291
  /** Operators that do not require a value (empty/is not empty) */
284
292
  export const VALUE_LESS_OPERATORS: ConditionOperator[] = ['is_empty', 'is_not_empty'];
285
293
 
294
+ export interface RadioGroupOption {
295
+ id: string;
296
+ label: string;
297
+ }
298
+
286
299
  export interface DataPointOption {
287
300
  fieldType: 'number' | 'string';
288
301
  fullKey: string;
289
302
  title: string;
290
303
  formSnapshot?: FormulaFieldFormSnapshot;
304
+ isRadioGroup?: boolean;
305
+ radioOptions?: RadioGroupOption[];
291
306
  }
@@ -122,6 +122,41 @@ describe('getDocumentFieldsDataPointOptions', () => {
122
122
  { fieldType: 'string', fullKey: 'fillable_a_b', title: 'fillable_a_b' },
123
123
  ]);
124
124
  });
125
+
126
+ test('groups radio fields with the same path into a single option using groupName', () => {
127
+ const sharedPath = 'fillable_signer1_payment';
128
+ const fields = [
129
+ buildField({
130
+ id: 'radio-yes',
131
+ path: sharedPath,
132
+ subType: 'radio',
133
+ label: 'Yes',
134
+ groupName: 'Payment Method',
135
+ }),
136
+ buildField({
137
+ id: 'radio-no',
138
+ path: sharedPath,
139
+ subType: 'radio',
140
+ label: 'No',
141
+ groupName: 'Payment Method',
142
+ }),
143
+ buildField({ id: 'text-1', path: 'fillable_signer1_name', label: 'Name', subType: 'text' }),
144
+ ];
145
+
146
+ expect(subject(fields)).toEqual([
147
+ { fieldType: 'string', fullKey: 'fillable_signer1_name', title: 'Name' },
148
+ {
149
+ fieldType: 'string',
150
+ fullKey: sharedPath,
151
+ title: 'Payment Method',
152
+ isRadioGroup: true,
153
+ radioOptions: [
154
+ { id: 'radio-no', label: 'No' },
155
+ { id: 'radio-yes', label: 'Yes' },
156
+ ],
157
+ },
158
+ ]);
159
+ });
125
160
  });
126
161
 
127
162
  describe('getDataPointOptions', () => {
@@ -58,6 +58,7 @@ export function getSchemaDataPointOptions(schema: SchemaObject | undefined): Dat
58
58
  /**
59
59
  * Data point options from document fields: fillable only (no calculated fields).
60
60
  * Uses field.path as fullKey so condition evaluation can read from the same data object.
61
+ * Radio buttons sharing the same path are grouped into a single option using groupName.
61
62
  */
62
63
  export function getDocumentFieldsDataPointOptions(
63
64
  fields: PdfField[] | undefined,
@@ -66,17 +67,47 @@ export function getDocumentFieldsDataPointOptions(
66
67
  return [];
67
68
  }
68
69
  const result: DataPointOption[] = [];
69
- for (const f of fields) {
70
- if (!f.path || f.type !== FieldTypeEnum.fillable) {
70
+ const radioGroupsByPath = new Map<string, PdfField[]>();
71
+
72
+ for (const field of fields) {
73
+ if (!field.path || field.type !== FieldTypeEnum.fillable) {
74
+ continue;
75
+ }
76
+ if (field.subType === 'radio') {
77
+ const group = radioGroupsByPath.get(field.path) ?? [];
78
+ group.push(field);
79
+ radioGroupsByPath.set(field.path, group);
71
80
  continue;
72
81
  }
73
- const fieldType = f.subType === 'number' ? 'number' : 'string';
82
+ const fieldType = field.subType === 'number' ? 'number' : 'string';
74
83
  result.push({
75
84
  fieldType,
76
- fullKey: f.path,
77
- title: f.label || f.path,
85
+ fullKey: field.path,
86
+ title: field.label || field.path,
78
87
  });
79
88
  }
89
+
90
+ for (const [path, radioFields] of radioGroupsByPath) {
91
+ const sortedRadioFields = [...radioFields].sort((a, b) =>
92
+ (a.label || '').localeCompare(b.label || ''),
93
+ );
94
+ const groupName =
95
+ sortedRadioFields.find(radioField => radioField.groupName)?.groupName ??
96
+ sortedRadioFields[0]?.label ??
97
+ path;
98
+
99
+ result.push({
100
+ fieldType: 'string',
101
+ fullKey: path,
102
+ title: groupName,
103
+ isRadioGroup: true,
104
+ radioOptions: sortedRadioFields.map(radioField => ({
105
+ id: radioField.id,
106
+ label: radioField.label || radioField.id,
107
+ })),
108
+ });
109
+ }
110
+
80
111
  return result.sort((a, b) => a.title.localeCompare(b.title));
81
112
  }
82
113