@servicetitan/dte-pdf-editor 1.86.0 → 1.88.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 (96) hide show
  1. package/README.md +37 -30
  2. package/dist/components/display-conditions/condition-row.d.ts.map +1 -1
  3. package/dist/components/display-conditions/condition-row.js +51 -22
  4. package/dist/components/display-conditions/condition-row.js.map +1 -1
  5. package/dist/components/display-conditions/condition-value-control.d.ts.map +1 -1
  6. package/dist/components/display-conditions/condition-value-control.js +1 -2
  7. package/dist/components/display-conditions/condition-value-control.js.map +1 -1
  8. package/dist/components/display-conditions/display-condition-modal.d.ts.map +1 -1
  9. package/dist/components/display-conditions/display-condition-modal.js +6 -49
  10. package/dist/components/display-conditions/display-condition-modal.js.map +1 -1
  11. package/dist/components/field-config-panel/e-sign-date-rules-summary.d.ts +11 -0
  12. package/dist/components/field-config-panel/e-sign-date-rules-summary.d.ts.map +1 -0
  13. package/dist/components/field-config-panel/e-sign-date-rules-summary.js +36 -0
  14. package/dist/components/field-config-panel/e-sign-date-rules-summary.js.map +1 -0
  15. package/dist/components/field-config-panel/e-sign-date-signed-offset-modal.d.ts +16 -0
  16. package/dist/components/field-config-panel/e-sign-date-signed-offset-modal.d.ts.map +1 -0
  17. package/dist/components/field-config-panel/e-sign-date-signed-offset-modal.js +62 -0
  18. package/dist/components/field-config-panel/e-sign-date-signed-offset-modal.js.map +1 -0
  19. package/dist/components/field-config-panel/e-sign-offset-rule-row.d.ts +16 -0
  20. package/dist/components/field-config-panel/e-sign-offset-rule-row.d.ts.map +1 -0
  21. package/dist/components/field-config-panel/e-sign-offset-rule-row.js +25 -0
  22. package/dist/components/field-config-panel/e-sign-offset-rule-row.js.map +1 -0
  23. package/dist/components/field-config-panel/e-sign-options-modal.d.ts +15 -0
  24. package/dist/components/field-config-panel/e-sign-options-modal.d.ts.map +1 -0
  25. package/dist/components/field-config-panel/e-sign-options-modal.js +26 -0
  26. package/dist/components/field-config-panel/e-sign-options-modal.js.map +1 -0
  27. package/dist/components/field-config-panel/field-config-panel.d.ts.map +1 -1
  28. package/dist/components/field-config-panel/field-config-panel.js +30 -28
  29. package/dist/components/field-config-panel/field-config-panel.js.map +1 -1
  30. package/dist/components/field-sidebar/e-sign-field-type-list.d.ts.map +1 -1
  31. package/dist/components/field-sidebar/e-sign-field-type-list.js +1 -6
  32. package/dist/components/field-sidebar/e-sign-field-type-list.js.map +1 -1
  33. package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.d.ts.map +1 -1
  34. package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.js +22 -8
  35. package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.js.map +1 -1
  36. package/dist/components/pdf-view/pdf-view-fillable.d.ts.map +1 -1
  37. package/dist/components/pdf-view/pdf-view-fillable.js +3 -3
  38. package/dist/components/pdf-view/pdf-view-fillable.js.map +1 -1
  39. package/dist/constants/field.constants.d.ts +1 -5
  40. package/dist/constants/field.constants.d.ts.map +1 -1
  41. package/dist/constants/field.constants.js +21 -7
  42. package/dist/constants/field.constants.js.map +1 -1
  43. package/dist/hooks/usePdfFieldDnD.d.ts.map +1 -1
  44. package/dist/hooks/usePdfFieldDnD.js +13 -5
  45. package/dist/hooks/usePdfFieldDnD.js.map +1 -1
  46. package/dist/interface/types.d.ts +26 -1
  47. package/dist/interface/types.d.ts.map +1 -1
  48. package/dist/interface/types.js +1 -0
  49. package/dist/interface/types.js.map +1 -1
  50. package/dist/styles/field-config-panel.css +11 -0
  51. package/dist/styles/field-type.css +12 -0
  52. package/dist/styles/index.css +1 -0
  53. package/dist/styles/variables.css +2 -0
  54. package/dist/utils/conditions/condition-match-type.utils.d.ts +14 -0
  55. package/dist/utils/conditions/condition-match-type.utils.d.ts.map +1 -0
  56. package/dist/utils/conditions/condition-match-type.utils.js +50 -0
  57. package/dist/utils/conditions/condition-match-type.utils.js.map +1 -0
  58. package/dist/utils/conditions/e-sign-offset-rules.utils.d.ts +12 -0
  59. package/dist/utils/conditions/e-sign-offset-rules.utils.d.ts.map +1 -0
  60. package/dist/utils/conditions/e-sign-offset-rules.utils.js +19 -0
  61. package/dist/utils/conditions/e-sign-offset-rules.utils.js.map +1 -0
  62. package/dist/utils/conditions/index.d.ts +2 -0
  63. package/dist/utils/conditions/index.d.ts.map +1 -1
  64. package/dist/utils/conditions/index.js +2 -0
  65. package/dist/utils/conditions/index.js.map +1 -1
  66. package/dist/utils/field/field-placeholder-text.utils.d.ts +1 -0
  67. package/dist/utils/field/field-placeholder-text.utils.d.ts.map +1 -1
  68. package/dist/utils/field/field-placeholder-text.utils.js +9 -4
  69. package/dist/utils/field/field-placeholder-text.utils.js.map +1 -1
  70. package/package.json +1 -1
  71. package/src/__tests__/field-types/custom-fields.test.ts +187 -0
  72. package/src/components/display-conditions/condition-row.tsx +114 -131
  73. package/src/components/display-conditions/condition-value-control.tsx +1 -2
  74. package/src/components/display-conditions/display-condition-modal.tsx +13 -63
  75. package/src/components/field-config-panel/e-sign-date-rules-summary.tsx +114 -0
  76. package/src/components/field-config-panel/e-sign-date-signed-offset-modal.tsx +197 -0
  77. package/src/components/field-config-panel/e-sign-offset-rule-row.tsx +91 -0
  78. package/src/components/field-config-panel/e-sign-options-modal.tsx +112 -0
  79. package/src/components/field-config-panel/field-config-panel.tsx +70 -91
  80. package/src/components/field-sidebar/e-sign-field-type-list.tsx +11 -14
  81. package/src/components/pdf-fields-overlay/pdf-overlay-field-fillable.tsx +46 -14
  82. package/src/components/pdf-view/pdf-view-fillable.tsx +3 -2
  83. package/src/constants/field.constants.ts +21 -8
  84. package/src/hooks/usePdfFieldDnD.ts +17 -2
  85. package/src/interface/types.ts +34 -1
  86. package/src/styles/field-config-panel.css +11 -0
  87. package/src/styles/field-type.css +12 -0
  88. package/src/styles/index.css +1 -0
  89. package/src/styles/variables.css +2 -0
  90. package/src/utils/conditions/__tests__/condition-match-type.utils.test.ts +104 -0
  91. package/src/utils/conditions/__tests__/e-sign-offset-rules.utils.test.ts +51 -0
  92. package/src/utils/conditions/condition-match-type.utils.ts +80 -0
  93. package/src/utils/conditions/e-sign-offset-rules.utils.ts +32 -0
  94. package/src/utils/conditions/index.ts +2 -0
  95. package/src/utils/field/__tests__/field-placeholder-text.utils.test.ts +17 -1
  96. package/src/utils/field/field-placeholder-text.utils.ts +10 -4
@@ -1,7 +1,11 @@
1
1
  import { FC, Fragment } from 'react';
2
2
  import { v4 as uuidv4 } from 'uuid';
3
3
  import { type PdfField } from '../../interface/types';
4
- import { getFieldPlaceholderText, getFieldTextClipClassName } from '../../utils';
4
+ import {
5
+ getFieldPlaceholderText,
6
+ getFieldTextClipClassName,
7
+ getFillablePaintedLabel,
8
+ } from '../../utils';
5
9
  import { AddHandle } from './add-handle';
6
10
 
7
11
  interface PdfOverlayFieldFillableProps {
@@ -20,29 +24,57 @@ export const PdfOverlayFieldFillable: FC<PdfOverlayFieldFillableProps> = ({
20
24
  switch (field.subType) {
21
25
  case 'checkbox':
22
26
  return (
23
- <input
24
- type="checkbox"
25
- readOnly
26
- className="dte-pdf-field-fillable"
27
+ <div
28
+ className="dte-pdf-field-content"
27
29
  style={{
28
- width: field.width,
29
- height: field.height,
30
+ display: 'flex',
31
+ alignItems: 'center',
32
+ background: 'inherit',
30
33
  }}
31
- />
32
- );
33
-
34
- case 'radio':
35
- return (
36
- <Fragment>
34
+ >
37
35
  <input
38
- type="radio"
36
+ title={field.label}
37
+ type="checkbox"
39
38
  readOnly
40
39
  className="dte-pdf-field-fillable"
41
40
  style={{
42
41
  width: field.width,
43
42
  height: field.height,
43
+ margin: 0,
44
+ border: 'none',
44
45
  }}
45
46
  />
47
+ {getFillablePaintedLabel(field)}
48
+ </div>
49
+ );
50
+
51
+ case 'radio':
52
+ return (
53
+ <Fragment>
54
+ <div
55
+ className="dte-pdf-field-content"
56
+ style={{
57
+ display: 'flex',
58
+ alignItems: 'center',
59
+ background: 'inherit',
60
+ width: '100%',
61
+ height: '100%',
62
+ }}
63
+ >
64
+ <input
65
+ title={field.label}
66
+ type="radio"
67
+ readOnly
68
+ className="dte-pdf-field-fillable"
69
+ style={{
70
+ width: field.width,
71
+ height: field.height,
72
+ margin: 0,
73
+ border: 'none',
74
+ }}
75
+ />
76
+ {getFillablePaintedLabel(field)}
77
+ </div>
46
78
  <AddHandle
47
79
  onClick={() =>
48
80
  handleAddNewField({
@@ -9,6 +9,7 @@ import {
9
9
  dateToUtcZero,
10
10
  getFieldPlaceholderText,
11
11
  getFieldTextClipClassName,
12
+ getFillablePaintedLabel,
12
13
  toDateInputValue,
13
14
  } from '../../utils';
14
15
 
@@ -132,7 +133,7 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
132
133
  name={field.path}
133
134
  onChange={e => handleDataChange(e.target.checked)}
134
135
  />
135
- {!field.hideLabel && field.label}
136
+ {getFillablePaintedLabel(field)}
136
137
  </div>
137
138
  );
138
139
  }
@@ -164,7 +165,7 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
164
165
  name={field.path}
165
166
  onChange={() => handleDataChange(field.id)}
166
167
  />
167
- {!field.hideLabel && field.label}
168
+ {getFillablePaintedLabel(field)}
168
169
  </div>
169
170
  );
170
171
  }
@@ -43,13 +43,6 @@ export const GENERIC_FIELD_DEFAULT_SIZES = {
43
43
  table: { width: BASE_PAGE_WIDTH - 10, height: 2 * GENERIC_FIELD_TABLE_ROW_HEIGHT },
44
44
  } as Record<PdfFieldSubType, { width: number; height: number }>;
45
45
 
46
- export const E_SIGN_FIELD_TYPE_OPTIONS: { name: string; id: ESignFieldType }[] = [
47
- { name: 'Signature', id: ESignFieldType.signature },
48
- { name: 'Initials', id: ESignFieldType.initials },
49
- { name: 'Date Signed', id: ESignFieldType.dateSigned },
50
- { name: 'Full Name', id: ESignFieldType.fullName },
51
- ];
52
-
53
46
  export const FILLABLE_FIELD_TYPES: FieldTypeOption[] = [
54
47
  { label: 'Text Field', type: FieldTypeEnum.fillable, subType: 'text' },
55
48
  { label: 'Number Field', type: FieldTypeEnum.fillable, subType: 'number' },
@@ -60,10 +53,30 @@ export const FILLABLE_FIELD_TYPES: FieldTypeOption[] = [
60
53
 
61
54
  export const E_SIGN_FIELD_TYPES: FieldTypeOption[] = [
62
55
  {
63
- label: 'E-Sign',
56
+ label: 'Signature',
64
57
  type: FieldTypeEnum.eSign,
65
58
  subType: ESignFieldType.signature,
66
59
  },
60
+ {
61
+ label: 'Initials',
62
+ type: FieldTypeEnum.eSign,
63
+ subType: ESignFieldType.initials,
64
+ },
65
+ {
66
+ label: 'Date Signed',
67
+ type: FieldTypeEnum.eSign,
68
+ subType: ESignFieldType.dateSigned,
69
+ },
70
+ {
71
+ label: 'Date Signed Offset',
72
+ type: FieldTypeEnum.eSign,
73
+ subType: ESignFieldType.dateSignedOffset,
74
+ },
75
+ {
76
+ label: 'Full Name',
77
+ type: FieldTypeEnum.eSign,
78
+ subType: ESignFieldType.fullName,
79
+ },
67
80
  ];
68
81
 
69
82
  export const CALCULATED_FIELD_TYPES: FieldTypeOption[] = [
@@ -29,19 +29,33 @@ type InitializeDroppedField = (
29
29
  baseField: PdfField,
30
30
  option: FieldTypeOption,
31
31
  recipients: RecipientInfo[],
32
+ fields: PdfField[],
32
33
  ) => PdfField;
33
34
 
34
35
  const initializeDroppedFieldByType: Record<FieldTypeEnum, InitializeDroppedField> = {
35
- [FieldTypeEnum.eSign]: (base, option, recipients) => {
36
+ [FieldTypeEnum.eSign]: (base, option, recipients, fields) => {
36
37
  const recipientName = recipients[0]?.name ?? '';
37
- return {
38
+ const field = {
38
39
  ...base,
39
40
  recipient: recipientName,
40
41
  path: generateESignPath(recipientName, option.subType as ESignFieldType),
41
42
  };
43
+
44
+ if (field.subType === ESignFieldType.dateSignedOffset) {
45
+ const existingDateSignedOffsetField = fields.find(
46
+ existingField =>
47
+ existingField.type === FieldTypeEnum.eSign &&
48
+ existingField.subType === ESignFieldType.dateSignedOffset,
49
+ );
50
+ field.data = existingDateSignedOffsetField?.data ?? {
51
+ rules: [{ days: 0 }],
52
+ };
53
+ }
54
+ return field;
42
55
  },
43
56
  [FieldTypeEnum.fillable]: (base, option, recipients) => {
44
57
  const recipientName = getFillableFieldsRecipients(recipients)[0]?.name ?? '';
58
+
45
59
  const defaultSize = FILLABLE_FIELD_DEFAULT_SIZES[base.subType!];
46
60
  return {
47
61
  ...base,
@@ -216,6 +230,7 @@ export const usePdfFieldDnD = ({
216
230
  baseField,
217
231
  draggedFieldOption,
218
232
  recipients,
233
+ fields,
219
234
  );
220
235
 
221
236
  onFieldsChange([...fields, newField]);
@@ -11,6 +11,7 @@ export enum ESignFieldType {
11
11
  signature = 'signature',
12
12
  initials = 'initials',
13
13
  dateSigned = 'dateSigned',
14
+ dateSignedOffset = 'dateSignedOffset',
14
15
  fullName = 'fullName',
15
16
  }
16
17
 
@@ -56,7 +57,39 @@ export interface GenericFieldTextDataType {
56
57
  value: string;
57
58
  }
58
59
 
59
- export type FieldDataType = GenericFieldTableDataType | GenericFieldTextDataType;
60
+ export interface ESignDateSignedOffsetSettingsRule {
61
+ days: number;
62
+ condition: DisplayConditionState;
63
+ }
64
+
65
+ export interface ESignDateSignedOffsetSettingsFinalRule {
66
+ days: number;
67
+ condition?: never;
68
+ }
69
+
70
+ /**
71
+ * Ordered "if/else if/.../else" chain of day-offset rules for an e-sign `dateSignedOffset` field.
72
+ * Rules are evaluated in array order; the first rule whose `condition` matches applies its `days`.
73
+ *
74
+ * The tuple shape encodes two invariants the UI and evaluator both rely on:
75
+ * - The last element is always the trailing else case: no `condition`, so it always matches.
76
+ * - Every element before it is a normal, conditioned rule (`ESignDateSignedOffsetSettingsRule`).
77
+ *
78
+ * `rules` therefore always has at least one entry (just the else rule) and never has a
79
+ * condition-less rule anywhere but last. `ESignOptionsModal` is the only place that splits this
80
+ * tuple apart (the else row is edited inline; the conditioned rules are edited in
81
+ * `ESignDateSignedOffsetModal`) and reassembles it before saving.
82
+ */
83
+ export interface ESignDateSignedOffsetSettings {
84
+ rules:
85
+ | [ESignDateSignedOffsetSettingsFinalRule]
86
+ | [...ESignDateSignedOffsetSettingsRule[], ESignDateSignedOffsetSettingsFinalRule];
87
+ }
88
+
89
+ export type FieldDataType =
90
+ | GenericFieldTableDataType
91
+ | GenericFieldTextDataType
92
+ | ESignDateSignedOffsetSettings;
60
93
 
61
94
  export interface DisplayConditionState {
62
95
  behavior: 'show' | 'hide';
@@ -0,0 +1,11 @@
1
+ .dte-rule-card {
2
+ padding: 12px;
3
+ background: var(--color-neutral-30, #f7f7f7);
4
+ border: 1px solid var(--border-color);
5
+ border-radius: var(--radius-2);
6
+
7
+ &.--active {
8
+ border-color: var(--border-color-active, #2270ee);
9
+ background: var(--bg-color-secondary-active, #fafcff);
10
+ }
11
+ }
@@ -15,3 +15,15 @@
15
15
  .dte-field-type-item:hover {
16
16
  box-shadow: 0 0 2px 0 var(--border-color-active);
17
17
  }
18
+
19
+ .dte-field-type-items-wrapper {
20
+ &.--grid-2 {
21
+ display: grid;
22
+ grid-template-columns: 1fr 1fr;
23
+ gap: var(--spacing-1, 8px);
24
+
25
+ .dte-field-type-item {
26
+ margin-top: var(--spacing-0, 0);
27
+ }
28
+ }
29
+ }
@@ -10,3 +10,4 @@
10
10
  @import './field-type.css';
11
11
  @import './pdf-document-renderer.css';
12
12
  @import './inline-editable.css';
13
+ @import './field-config-panel.css';
@@ -28,11 +28,13 @@
28
28
  --color-neutral-70: var(--color-neutral-70, #737475);
29
29
  --color-neutral-200: var(--color-neutral-200, #6c6d70);
30
30
  --color-neutral-300: var(--color-neutral-300, #2d2e31);
31
+ --color-blue-100: var(--color-blue-100, #fafcff);
31
32
 
32
33
  /* Background colors */
33
34
  --bg-color-light: var(--color-neutral-0, #ffffff);
34
35
  --bg-color-main: var(--color-neutral-20, #fafafa);
35
36
  --bg-color-secondary: var(--color-neutral-50, #eeeeee);
37
+ --bg-color-secondary-active: var(--color-blue-100, #fafcff);
36
38
  --bg-color: var(--color-neutral-200, #f5f5f5);
37
39
  --bg-color-hover: #eaeaea;
38
40
 
@@ -0,0 +1,104 @@
1
+ import type { DisplayConditionSingle, DisplayConditionState } from '../../../interface/types';
2
+ import {
3
+ applyConditionMatchType,
4
+ deriveConditionMatchType,
5
+ isValidConditionGroups,
6
+ } from '../condition-match-type.utils';
7
+
8
+ const buildCondition = (overrides: Partial<DisplayConditionSingle> = {}): DisplayConditionSingle => ({
9
+ id: 'c1',
10
+ dataPointKey: '',
11
+ operator: 'is_equal_to',
12
+ value: '',
13
+ ...overrides,
14
+ });
15
+
16
+ describe('deriveConditionMatchType', () => {
17
+ test('defaults to "and" with a single condition', () => {
18
+ const state: DisplayConditionState = {
19
+ behavior: 'show',
20
+ groups: [{ id: 'g1', conditions: [buildCondition()] }],
21
+ };
22
+ expect(deriveConditionMatchType(state)).toBe('and');
23
+ });
24
+
25
+ test('returns "or" when a later condition in the group carries an "or" join', () => {
26
+ const state: DisplayConditionState = {
27
+ behavior: 'show',
28
+ groups: [
29
+ {
30
+ id: 'g1',
31
+ conditions: [buildCondition(), buildCondition({ id: 'c2', logicalOperator: 'or' })],
32
+ },
33
+ ],
34
+ };
35
+ expect(deriveConditionMatchType(state)).toBe('or');
36
+ });
37
+
38
+ test('returns "or" when a later group carries an "or" join', () => {
39
+ const state: DisplayConditionState = {
40
+ behavior: 'show',
41
+ groups: [
42
+ { id: 'g1', conditions: [buildCondition()] },
43
+ { id: 'g2', logicalOperator: 'or', conditions: [buildCondition({ id: 'c2' })] },
44
+ ],
45
+ };
46
+ expect(deriveConditionMatchType(state)).toBe('or');
47
+ });
48
+ });
49
+
50
+ describe('applyConditionMatchType', () => {
51
+ test('stamps the match type onto every condition after the first, and every group after the first', () => {
52
+ const state: DisplayConditionState = {
53
+ behavior: 'show',
54
+ groups: [
55
+ {
56
+ id: 'g1',
57
+ conditions: [buildCondition(), buildCondition({ id: 'c2' })],
58
+ },
59
+ { id: 'g2', conditions: [buildCondition({ id: 'c3' })] },
60
+ ],
61
+ };
62
+ const next = applyConditionMatchType(state, 'or');
63
+ expect(next.groups[0].conditions[0].logicalOperator).toBeUndefined();
64
+ expect(next.groups[0].conditions[1].logicalOperator).toBe('or');
65
+ expect(next.groups[1].logicalOperator).toBe('or');
66
+ });
67
+ });
68
+
69
+ describe('isValidConditionGroups', () => {
70
+ const deps = { fillableOptions: [], formFieldsByFormId: {}, mergeTagOptions: [] };
71
+
72
+ test('is false when there are no groups', () => {
73
+ expect(isValidConditionGroups([], deps)).toBe(false);
74
+ });
75
+
76
+ test('is false when a condition has no data point selected', () => {
77
+ const groups = [{ id: 'g1', conditions: [buildCondition({ dataPointKey: '' })] }];
78
+ expect(isValidConditionGroups(groups, deps)).toBe(false);
79
+ });
80
+
81
+ test('is false when a value-requiring condition has a blank value', () => {
82
+ const groups = [
83
+ { id: 'g1', conditions: [buildCondition({ dataPointKey: 'name', value: ' ' })] },
84
+ ];
85
+ expect(isValidConditionGroups(groups, deps)).toBe(false);
86
+ });
87
+
88
+ test('is true for a value-less operator with no value', () => {
89
+ const groups = [
90
+ {
91
+ id: 'g1',
92
+ conditions: [buildCondition({ dataPointKey: 'name', operator: 'is_empty', value: '' })],
93
+ },
94
+ ];
95
+ expect(isValidConditionGroups(groups, deps)).toBe(true);
96
+ });
97
+
98
+ test('is true when the data point and value are both present', () => {
99
+ const groups = [
100
+ { id: 'g1', conditions: [buildCondition({ dataPointKey: 'name', value: 'Acme' })] },
101
+ ];
102
+ expect(isValidConditionGroups(groups, deps)).toBe(true);
103
+ });
104
+ });
@@ -0,0 +1,51 @@
1
+ import type { ESignDateSignedOffsetSettingsRule } from '../../../interface/types';
2
+ import {
3
+ addESignOffsetRule,
4
+ canAddESignOffsetRule,
5
+ createESignOffsetRule,
6
+ MAX_ESIGN_OFFSET_RULES,
7
+ updateESignOffsetRule,
8
+ } from '../e-sign-offset-rules.utils';
9
+
10
+ describe('createESignOffsetRule', () => {
11
+ test('creates a normal rule with a single default condition group', () => {
12
+ const rule = createESignOffsetRule();
13
+ expect(rule.days).toBe(0);
14
+ expect(rule.condition.behavior).toBe('show');
15
+ expect(rule.condition.groups).toHaveLength(1);
16
+ expect(rule.condition.groups[0].conditions).toHaveLength(1);
17
+ });
18
+ });
19
+
20
+ describe('addESignOffsetRule', () => {
21
+ test('appends a new rule after any existing rules, preserving their order', () => {
22
+ const first = createESignOffsetRule();
23
+ const withSecond = addESignOffsetRule([first]);
24
+ expect(withSecond).toHaveLength(2);
25
+ expect(withSecond[0]).toBe(first);
26
+
27
+ const withThird = addESignOffsetRule(withSecond);
28
+ expect(withThird).toHaveLength(3);
29
+ expect(withThird[0]).toBe(first);
30
+ expect(withThird[1]).toBe(withSecond[1]);
31
+ });
32
+
33
+ test('is a no-op once the max rule count is reached', () => {
34
+ let rules: ESignDateSignedOffsetSettingsRule[] = [];
35
+ for (let i = 0; i < MAX_ESIGN_OFFSET_RULES + 3; i++) {
36
+ rules = addESignOffsetRule(rules);
37
+ }
38
+ expect(rules).toHaveLength(MAX_ESIGN_OFFSET_RULES - 1);
39
+ expect(canAddESignOffsetRule(rules)).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe('updateESignOffsetRule', () => {
44
+ test('replaces the rule at the given index without mutating the input array', () => {
45
+ const rules = [createESignOffsetRule(), createESignOffsetRule()];
46
+ const updated: ESignDateSignedOffsetSettingsRule = { ...rules[1], days: 7 };
47
+ const next = updateESignOffsetRule(rules, 1, updated);
48
+ expect(next[1].days).toBe(7);
49
+ expect(rules[1].days).toBe(0);
50
+ });
51
+ });
@@ -0,0 +1,80 @@
1
+ import type {
2
+ DataPointOption,
3
+ DisplayConditionGroup,
4
+ DisplayConditionState,
5
+ FormFieldsByFormIdI,
6
+ } from '../../interface/types';
7
+ import { getDisplayConditionFieldTypeForKey } from '../formula';
8
+ import { isValidNumber } from '../shared';
9
+
10
+ export type ConditionMatchType = 'and' | 'or';
11
+
12
+ /** Derives All/Any (and/or) toggle value from the operators already stored on a condition state. */
13
+ export const deriveConditionMatchType = (
14
+ conditionState: DisplayConditionState,
15
+ ): ConditionMatchType => {
16
+ for (let g = 0; g < conditionState.groups.length; g++) {
17
+ const group = conditionState.groups[g];
18
+ if (g > 0 && group.logicalOperator === 'or') {
19
+ return 'or';
20
+ }
21
+ for (let i = 1; i < group.conditions.length; i++) {
22
+ if (group.conditions[i]?.logicalOperator === 'or') {
23
+ return 'or';
24
+ }
25
+ }
26
+ }
27
+ return 'and';
28
+ };
29
+
30
+ /** Stamps the given All/Any (and/or) value onto every group/condition after the first in each. */
31
+ export const applyConditionMatchType = (
32
+ conditionState: DisplayConditionState,
33
+ matchType: ConditionMatchType,
34
+ ): DisplayConditionState => ({
35
+ ...conditionState,
36
+ groups: conditionState.groups.map((group, groupIndex) => ({
37
+ ...group,
38
+ ...(groupIndex > 0 ? { logicalOperator: matchType } : {}),
39
+ conditions: group.conditions.map((c, i) =>
40
+ i === 0 ? { ...c, logicalOperator: undefined } : { ...c, logicalOperator: matchType },
41
+ ),
42
+ })),
43
+ });
44
+
45
+ export interface ConditionValidationDeps {
46
+ mergeTagOptions: DataPointOption[];
47
+ fillableOptions: DataPointOption[];
48
+ formFieldsByFormId?: FormFieldsByFormIdI;
49
+ }
50
+
51
+ /** True when every condition in every group has a data point and a usable value for its type. */
52
+ export const isValidConditionGroups = (
53
+ groups: DisplayConditionGroup[],
54
+ { fillableOptions, formFieldsByFormId, mergeTagOptions }: ConditionValidationDeps,
55
+ ): boolean => {
56
+ for (const group of groups) {
57
+ for (const condition of group.conditions) {
58
+ if (!condition.dataPointKey) {
59
+ return false;
60
+ }
61
+ if (condition.operator === 'is_empty' || condition.operator === 'is_not_empty') {
62
+ continue;
63
+ }
64
+ const trimmedValue = condition.value.trim();
65
+ if (!trimmedValue) {
66
+ return false;
67
+ }
68
+ const fieldType = getDisplayConditionFieldTypeForKey(
69
+ condition.dataPointKey,
70
+ mergeTagOptions,
71
+ fillableOptions,
72
+ formFieldsByFormId ?? {},
73
+ );
74
+ if (fieldType === 'number' && !isValidNumber(trimmedValue)) {
75
+ return false;
76
+ }
77
+ }
78
+ }
79
+ return groups.length > 0;
80
+ };
@@ -0,0 +1,32 @@
1
+ import { defaultGroup } from '../../constants';
2
+ import type { ESignDateSignedOffsetSettingsRule } from '../../interface/types';
3
+
4
+ /**
5
+ * Product limit: rules (the conditioned rules managed here, plus the trailing else row that
6
+ * lives outside this array) may not exceed this total.
7
+ */
8
+ export const MAX_ESIGN_OFFSET_RULES = 5;
9
+
10
+ export const createESignOffsetRule = (): ESignDateSignedOffsetSettingsRule => ({
11
+ days: 0,
12
+ condition: { behavior: 'show', groups: [defaultGroup()] },
13
+ });
14
+
15
+ export const canAddESignOffsetRule = (rules: ESignDateSignedOffsetSettingsRule[]): boolean =>
16
+ rules.length < MAX_ESIGN_OFFSET_RULES - 1;
17
+
18
+ /** Appends a new rule. No-op past the max. */
19
+ export const addESignOffsetRule = (
20
+ rules: ESignDateSignedOffsetSettingsRule[],
21
+ ): ESignDateSignedOffsetSettingsRule[] =>
22
+ canAddESignOffsetRule(rules) ? [...rules, createESignOffsetRule()] : rules;
23
+
24
+ export const updateESignOffsetRule = (
25
+ rules: ESignDateSignedOffsetSettingsRule[],
26
+ index: number,
27
+ rule: ESignDateSignedOffsetSettingsRule,
28
+ ): ESignDateSignedOffsetSettingsRule[] => {
29
+ const next = [...rules];
30
+ next[index] = rule;
31
+ return next;
32
+ };
@@ -1,2 +1,4 @@
1
+ export * from './condition-match-type.utils';
2
+ export * from './e-sign-offset-rules.utils';
1
3
  export * from './evaluate.utils';
2
4
  export * from './schema-data-points.utils';
@@ -1,5 +1,5 @@
1
1
  import { FieldTypeEnum, type PdfField } from '../../../interface/types';
2
- import { getFieldPlaceholderText } from '../field-placeholder-text.utils';
2
+ import { getFieldPlaceholderText, getFillablePaintedLabel } from '../field-placeholder-text.utils';
3
3
 
4
4
  const buildField = (overrides: Partial<PdfField> = {}): PdfField => ({
5
5
  id: 'field-1',
@@ -60,3 +60,19 @@ describe('getFieldPlaceholderText', () => {
60
60
  expect(subject(buildField({ hideLabel: false, required: true }))).toBe('Field Label *');
61
61
  });
62
62
  });
63
+
64
+ describe('getFillablePaintedLabel', () => {
65
+ const subject = (field: PdfField) => getFillablePaintedLabel(field);
66
+
67
+ test('returns the label so editor and fill paint the same adjacent text', () => {
68
+ expect(subject(buildField({ subType: 'checkbox' }))).toBe('Field Label');
69
+ });
70
+
71
+ test('returns empty when hideLabel is set, including in the authoring overlay', () => {
72
+ expect(subject(buildField({ subType: 'checkbox', hideLabel: true }))).toBe('');
73
+ });
74
+
75
+ test('treats an absent hideLabel as shown, so pre-existing fields keep their label', () => {
76
+ expect(subject(buildField({ subType: 'radio' }))).toBe('Field Label');
77
+ });
78
+ });
@@ -1,11 +1,17 @@
1
1
  import { type PdfField } from '../../interface/types';
2
2
 
3
+ export const getFillablePaintedLabel = (field: PdfField) => {
4
+ return field.hideLabel ? '' : field.label || '';
5
+ };
6
+
3
7
  export const getFieldPlaceholderText = (field: PdfField, prefix?: string): string => {
4
- // A hidden label takes its required marker with it - a lone '*' floating in an otherwise empty box
5
- // reads as stray ink on the document, not as a hint. Any prefix stays: it is a format hint supplied
6
- // by the caller (e.g. 'mm/dd/yyyy'), not part of the label.
8
+ /*
9
+ * A hidden label takes its required marker with it - a lone '*' floating in an otherwise empty box
10
+ * reads as stray ink on the document, not as a hint. Any prefix stays: it is a format hint supplied
11
+ * by the caller (e.g. 'mm/dd/yyyy'), not part of the label.
12
+ */
7
13
  const requiredIndicator = field.required && !field.hideLabel ? '*' : '';
8
- const label = field.hideLabel ? '' : field.label || '';
14
+ const label = getFillablePaintedLabel(field);
9
15
  const parts = [prefix, label, requiredIndicator].filter(Boolean);
10
16
  return parts.join(' ');
11
17
  };